Mapping utility service territories and outages with geocoding

Map utility service territories, classify outage addresses by district, and route field crews — all via reverse geocoding and boundaries REST endpoints.

| June 23, 2026
Mapping utility service territories and outages with geocoding

Every outage report is a coordinate. Every field crew is a territory. The gap between the two — "which district does this fault belong to, who owns the feeder, and which crew runs that feeder?" — is a classification problem that most utility and telecom operations teams solve with a static shapefile that someone exported from a GIS desktop in 2019 and embedded in the SCADA middleware. The shapefile is wrong in seventeen places, it has not been updated since the last franchise boundary renegotiation, and nobody wants to touch the middleware.

The alternative is a reverse-geocode plus a boundary lookup on every inbound event. This post shows how to do that — live, in production, fast enough to fit inside the latency budget of a field technician's radio ping, with two REST endpoints and a retry policy that survives a congested LTE cell during a storm event.

Why static shapefiles keep failing operations teams

The shapefile failure mode is not that the geometry is wrong; it is that the geometry is right on day one and then drifts away from reality silently. Utility franchise boundaries change when a municipality annexes a new subdivision. Telecom service territories shift when a carrier swaps a CLEC agreement. Distribution districts get redrawn when a new substation comes online. The shapefile in the middleware captures none of that unless someone with QGIS access and time to spare redoes the export.

Three failure patterns that recur.

Incorrect district assignment during a major event. A storm causes sixty simultaneous faults. Twenty-three of them are routed to District 4's crew because the shapefile still shows District 4 holding that quadrant. District 4 lost that quadrant eight months ago in a service area revision. The crew dispatched, the crew turned around, the customer sat in the dark an extra ninety minutes.

Cross-boundary billing errors. A telecom provisioning system assigns a new CPE installation to the wrong rate centre because the geocode for the address landed three metres across an administrative boundary. The boundary in the shapefile was drawn at the centreline of a road that has since been rerouted. The error compounds for twelve months before an audit catches it.

Outage heatmaps that mislead the NOC. If the boundary classification is wrong, the aggregated outage count per district is wrong. A network operations centre dashboard that shows "2 faults in District 7, 18 in District 6" when the real split is "9 and 11" will send the wrong teams to the wrong places for the wrong reasons.

All three failures have the same root cause: the boundary data is stale and the classification code trusts it unconditionally. A live boundary lookup removes the trust-and-hope dependency.

The two endpoints that handle this

`GET /api/v1/reverse` — takes a coordinate and returns the best address match along with administrative hierarchy (municipality, county, state, country, postal code). Use this when an outage is reported as a coordinate from a smart meter or a fibre-cut detection system and you need the human-readable address before logging it to your work-order system.

`GET /api/v1/boundaries` — takes a coordinate and returns every administrative and sub-administrative polygon that contains that point. In a utility context, "administrative polygon" maps cleanly to county, city, postal district, and census tract. Layer your own territory assignments on top of those stable identifiers. Use this when the question is classification: "which territory owns this lat/lng?" rather than "what is the mailing address?"

The two endpoints are independent and can be called in parallel if you need both the address label and the territory classification for the same event.

The data behind the classification

CSV2GEO's 461M+ addresses and 39 countries of coverage are what back the reverse geocoder. The boundary data is built from hierarchical administrative polygons — stable identifiers that do not drift the way a hand-drawn shapefile does. The right architecture for a utility or telecom operator is to maintain a lookup table that maps (admin_level_2_id, admin_level_3_id, postal_code) to your internal district or territory code, and to query the boundaries endpoint to populate that tuple at classification time rather than to try to embed a shapefile into your middleware.

That architecture means your territory definitions live in one place (your own database), the boundary data stays current (the API's job), and the join at classification time is a simple key lookup.

Building the outage classification pipeline

Here is the concrete pattern. An event arrives — a smart meter trips, a fibre splice reports an alarm, a customer calls in. The event carries a coordinate and a timestamp. We need to classify it to a territory and write a work order.

Step 1: Reverse-geocode the coordinate to a human-readable address

The work order needs a street address. The event gives you a lat/lng. One call:

curl -G "https://csv2geo.com/api/v1/reverse" \
  --data-urlencode "lat=41.8781" \
  --data-urlencode "lng=-87.6298" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

Response (abbreviated):

{
  "result": {
    "formatted_address": "77 W Jackson Blvd, Chicago, IL 60604, US",
    "confidence": 0.94,
    "components": {
      "road": "W Jackson Blvd",
      "city": "Chicago",
      "county": "Cook County",
      "state": "Illinois",
      "postcode": "60604",
      "country_code": "US"
    },
    "location": {
      "lat": 41.8781,
      "lng": -87.6298
    }
  }
}

The confidence field matters. A confidence below 0.7 is a signal that the reverse geocode matched to an interpolated or approximate address rather than a rooftop point. For a work order that a crew will drive to, a low confidence score should trigger a manual address review flag on the ticket — not a silent acceptance. See Reverse-Geocoding Accuracy and the Distance Meters for the full discussion of what confidence means in practice and how far off "approximate" can be.

In Python:

import os
import requests

API = "https://csv2geo.com/api/v1"
KEY = os.environ["CSV2GEO_KEY"]

def reverse_geocode(lat: float, lng: float) -> dict:
    r = requests.get(
        f"{API}/reverse",
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=10,
    )
    r.raise_for_status()
    return r.json()["result"]

result = reverse_geocode(41.8781, -87.6298)
print(result["formatted_address"])   # 77 W Jackson Blvd, Chicago, IL 60604, US
print(result["confidence"])          # 0.94

Step 2: Look up administrative boundaries to classify the territory

Now the classification call. Same coordinate, different endpoint:

curl -G "https://csv2geo.com/api/v1/boundaries" \
  --data-urlencode "lat=41.8781" \
  --data-urlencode "lng=-87.6298" \
  --data-urlencode "api_key=$CSV2GEO_KEY"

Response (abbreviated):

{
  "results": [
    {"level": "country",  "name": "United States", "code": "US"},
    {"level": "state",    "name": "Illinois",       "code": "IL"},
    {"level": "county",   "name": "Cook County",    "code": "US-IL-031"},
    {"level": "city",     "name": "Chicago",        "code": "US-IL-14000"},
    {"level": "postcode", "name": "60604",          "code": "60604"}
  ]
}

Your territory lookup table maps (county_code, postcode) → your internal district. A simple join:

TERRITORY_MAP = {
    ("US-IL-031", "60604"): "District-7-Metro-East",
    ("US-IL-031", "60290"): "District-7-Metro-West",
    # ... populated from your franchise agreement data
}

def classify_territory(lat: float, lng: float) -> str | None:
    r = requests.get(
        f"{API}/boundaries",
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=10,
    )
    r.raise_for_status()
    data = r.json()["results"]
    county = next((x["code"] for x in data if x["level"] == "county"), None)
    postcode = next((x["code"] for x in data if x["level"] == "postcode"), None)
    return TERRITORY_MAP.get((county, postcode))

When TERRITORY_MAP.get(...) returns None, the event lands in a review queue rather than being silently dropped or misassigned. That is the right failure mode — a visible gap in your territory map is recoverable; a silent misroute is not.

Step 3: Run both calls in parallel for events that need both

Waiting for the reverse-geocode response before firing the boundaries call costs you an unnecessary serial round-trip. If you need both the formatted address and the territory classification for the same event — which is the common case in a work-order system — run them concurrently:

import asyncio
import httpx

async def enrich_event(lat: float, lng: float) -> dict:
    async with httpx.AsyncClient(timeout=10) as client:
        rev_task = client.get(
            f"{API}/reverse",
            params={"lat": lat, "lng": lng, "api_key": KEY},
        )
        bnd_task = client.get(
            f"{API}/boundaries",
            params={"lat": lat, "lng": lng, "api_key": KEY},
        )
        rev_r, bnd_r = await asyncio.gather(rev_task, bnd_task)
        rev_r.raise_for_status()
        bnd_r.raise_for_status()

    address = rev_r.json()["result"]["formatted_address"]
    confidence = rev_r.json()["result"]["confidence"]
    boundaries = bnd_r.json()["results"]
    county = next((x["code"] for x in boundaries if x["level"] == "county"), None)
    postcode = next((x["code"] for x in boundaries if x["level"] == "postcode"), None)
    territory = TERRITORY_MAP.get((county, postcode))

    return {
        "address": address,
        "confidence": confidence,
        "territory": territory,
        "county_code": county,
        "postcode": postcode,
    }

In Node:

const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_KEY;

async function enrichEvent(lat, lng) {
  const base = `${API}`;
  const params = new URLSearchParams({ lat, lng, api_key: KEY });

  const [revRes, bndRes] = await Promise.all([
    fetch(`${base}/reverse?${params}`),
    fetch(`${base}/boundaries?${params}`),
  ]);

  if (!revRes.ok) throw new Error(`reverse http ${revRes.status}`);
  if (!bndRes.ok) throw new Error(`boundaries http ${bndRes.status}`);

  const rev = await revRes.json();
  const bnd = await bndRes.json();

  const county   = bnd.results.find(x => x.level === 'county')?.code   ?? null;
  const postcode = bnd.results.find(x => x.level === 'postcode')?.code ?? null;
  const territory = TERRITORY_MAP[`${county}:${postcode}`] ?? null;

  return {
    address:    rev.result.formatted_address,
    confidence: rev.result.confidence,
    territory,
    county,
    postcode,
  };
}

Two round-trips collapsed into one wall-clock latency slot. On a field technician's mobile device during a storm event, that matters.

Step 4: Cache aggressively — addresses and boundaries do not move

Once you have classified a coordinate to a territory, that classification is stable for months. Postal boundaries do not shift weekly. County lines do not redraw overnight. Cache the result of (lat_rounded_4dp, lng_rounded_4dp){address, territory, county_code, postcode} in Redis with a TTL of 24 hours for high-churn assets like customer tickets, or 30 days for infrastructure assets like pole and splice locations that are unlikely to change territory assignment.

The cache hit rate for a typical utility event stream — where many events cluster around the same infrastructure points — often exceeds 60% within the first week of a deployment. At that hit rate, you are paying for classification on 40% of events and serving the rest from cache at essentially zero cost. See Caching Geocoding Results — 90% Cost Reduction for the full Redis TTL strategy and the rounding strategy that maximises cache hit rates without losing meaningful geographic precision.

Step 5: Build the outage heatmap from classified events

Once every event carries a territory, county_code, and postcode, the outage heatmap becomes a simple GROUP BY in whatever analytics store you already operate. A query that was previously impossible — "show me fault density per territory over the last 72 hours, broken down by event type" — becomes trivial when the classification is already on the row.

The ops dashboard pattern that works: keep a materialized view or an hourly rollup table keyed by (territory, event_type, hour_bucket). Your NOC dashboard queries that rollup, not the raw event log. The classification API is on the hot path for individual events; it is entirely off the hot path for the dashboard.

Tying into your observability stack is straightforward if you instrument the classification function correctly — emit outage_classification_success, outage_classification_miss (territory not in map), and outage_classification_error (API returned non-2xx) as counters, and classification_duration_ms as a histogram. Observability for Geocoding Pipelines covers the full metrics schema that keeps this kind of pipeline visible to your on-call team at 3 a.m.

The crew-dispatch integration

Classification tells you which territory owns an event. Dispatch tells you which crew to send. The two are separate concerns and should be separate lookups, but the output of the classification step is the key into the dispatch system.

A crew assignment is driven by: territory (which crews are licensed for this territory?), crew_location (which licensed crew is closest to the fault coordinate?), and crew_status (which of those crews are available?). The first is a lookup against your territory-to-crew table. The second is a nearest-neighbour query against current crew positions. The third is a status check against your field-force management system.

For the nearest-neighbour query, reverse-geocoding the crew position into the same administrative hierarchy and comparing it to the fault's hierarchy gives you a coarse "is this crew in the right general area?" check that is faster to compute than a raw Haversine distance against a thousand crew coordinates. In practice: filter to crews whose territory matches, then rank by Haversine distance. At 5,000 events per day — the kind of volume a mid-size distribution utility sees during a major weather event — this pipeline stays well within the free tier's 3,000 calls/day for classification (most events are cache hits) and uses API budget only for genuine cache misses and new infrastructure addresses. For scale, the dispatch console architecture in Dispatch Console — 5,000 Stops per Day maps almost exactly to this pattern.

Handling the boundary edge cases

Three cases that trip up utility and telecom teams specifically.

Addresses that sit exactly on a boundary. A substation at the boundary of two postal districts can resolve to either, depending on floating-point representation. The boundaries endpoint returns all polygons that contain the point — if two districts both claim the coordinate, you get two postcode entries in the response. Your territory map needs a tiebreak rule (first wins, or a specific district takes precedence, or it lands in review). The worst outcome is to silently pick one at random; that produces intermittently wrong assignments that are very difficult to debug after the fact.

Rural coordinates with no postcode match. Agricultural feeders and rural fibre runs often stretch through areas where postal coverage is sparse. The postcode level entry may be absent from the boundaries response. Your territory lookup must tolerate a None postcode and fall back to county-level classification, with a "postcode missing — classified by county only" flag on the work order.

Coordinates outside the 39-country coverage footprint. If your network extends beyond the 39 countries in the coverage set, the reverse-geocode will return a lower-confidence match or an error, and the boundaries endpoint will return an empty result set. Build the out-of-coverage path explicitly — flag the work order as "manual territory assignment required" and route it to your GIS team. Do not silently assign it to a default territory.

Telecom-specific patterns

Utility operations and telecom network operations share the architecture above, but telecom adds two wrinkles worth addressing directly.

Rate-centre classification. Telecom provisioning systems assign services to rate centres — a regulatory geography that does not map cleanly to county or postal boundaries. The right pattern is to maintain a rate-centre lookup table keyed on (state_code, county_code, postal_code) and use the boundary response to populate the lookup key, exactly as you would for a utility district. The rate-centre table itself comes from your tariff filings and does not belong in the geocoding API — the API's job is to tell you which administrative identifiers the coordinate sits inside; your table tells you what those identifiers mean for your business.

CPE installation address validation. When a technician provisions a new CPE at a customer address, the installation record must carry the correct service address for billing, regulatory reporting, and eventual PSAP emergency routing. Reverse-geocoding the CPE's GPS coordinate at provisioning time gives you the address; running it through the boundaries endpoint gives you the rate centre and the jurisdiction for 911 routing. Both lookups are synchronous in the provisioning workflow — the technician taps "confirm install" and the mobile app makes both calls before writing the record. A confidence below 0.8 surfaces a "please verify address" prompt in the app. This is the correct UX; it is cheaper to ask the technician to confirm than to spend three support calls unravelling a wrong rate-centre assignment six months later.

Failure modes and the retry policy

The classification pipeline is on the hot path for outage response. It must be more reliable than the network events it is classifying. Two failure modes to design for explicitly.

API timeout during a storm event. Storm events that produce outages also produce congested mobile networks. A 10-second timeout that is appropriate on a quiet morning is too long in a field device's context. Use a 4-second timeout for the first attempt, then retry with exponential backoff capped at 3 attempts before falling back to cached data or to a manual classification queue. The retry policy from Exponential Backoff — When to Retry, When to Stop applies directly; the only utility-specific addition is that the fallback should be "serve cached result if available" rather than "return an error," because a stale classification is more useful to a dispatcher than a blank.

Rate limit during a bulk event ingestion. A major storm that generates 10,000 fault reports in 30 minutes can exhaust a per-minute rate limit if the ingestion pipeline fires one API call per event without batching. For the reverse-geocode use case, batch where you can — the /api/v1/geocode batch endpoint accepts multiple queries. For the boundaries endpoint, events that share the same 4-decimal-place lat/lng (which happens constantly for infrastructure coordinates — a pole location gets reported many times in a storm) should be deduped before the API call and the result fanned back out to all matching events from cache. A 60% dedup rate on a storm event stream is a conservative estimate; most utilities see much higher repetition rates on their fixed infrastructure coordinates.

Cost model for a mid-size utility

A regional distribution utility running 5,000 outage events per day during peak season, with 60% cache hit rate on classification:

  • 2,000 reverse-geocode calls/day (cache misses × 2 endpoints, after dedup)
  • 2,000 boundaries calls/day (same)
  • Total: 4,000 API calls/day

The free tier provides 3,000 calls/day without a credit card — almost enough for off-peak operation. For peak-season production, the entry paid tier at $54/month for 100,000 calls covers roughly 50 days of peak-season usage, or the full year at average-day volumes. See csv2geo.com/pricing/api for the current tier structure.

SDKs are available for Python and Node, but the REST layer is simple enough that most utility integration teams write their own thin wrapper — one function for reverse, one for boundaries, one shared retry decorator — and never depend on an SDK version. The pattern above is exactly what production deployments look like.

Frequently Asked Questions

How does the boundaries endpoint differ from a reverse geocode? Reverse geocoding returns a formatted street address and its administrative components as metadata. The boundaries endpoint returns the set of polygons — at every administrative level — that contain the coordinate, with stable identifiers for each. Reverse geocoding answers "what is the address here?" Boundaries answers "which jurisdictions does this point fall inside?" Use reverse geocoding for work-order labels; use boundaries for territory classification.

Can I map our internal territory codes directly to the API's polygon identifiers? Yes — and that is the recommended pattern. Maintain a lookup table that maps API polygon identifiers (county code, postcode, city code) to your internal district or territory codes. Your territory definitions live in your own database; the API's job is to tell you which identifiers apply to a given coordinate. When your territory boundaries change, you update your lookup table, not the API integration.

What happens if an event coordinate lands on a boundary between two territories? The boundaries endpoint returns all polygons that contain the point. If two postal districts both claim the coordinate, you will see two postcode-level entries. Your territory lookup must implement a tiebreak rule — first match, higher-priority district, or explicit review queue. Design the tiebreak before you ship; ambiguous boundary cases are common near road centrelines and will occur in production.

Is the coverage sufficient for rural utility networks? The 461M+ address database covers urban and suburban areas well. For rural coordinates — agricultural feeders, remote fibre runs — the reverse-geocode confidence may be lower and the postcode-level boundary entry may be absent. Build your pipeline to tolerate a missing postcode and fall back to county-level classification. Flag work orders classified by county only so your GIS team can validate the territory assignment.

How should I handle the retry policy on a congested mobile network during a storm? Use a short first-attempt timeout (3-4 seconds) rather than the default 10 seconds. Retry up to three times with exponential backoff. On final failure, serve a cached classification if available; otherwise flag the event for manual territory assignment. A stale classification from cache is more useful to a dispatcher than a blank field. The full retry pattern is covered in Exponential Backoff — When to Retry, When to Stop.

Does this work for telecom rate-centre classification as well as utility districts? Yes, with a layer of your own mapping on top. The boundaries endpoint returns the administrative identifiers (state, county, postal code) that your rate-centre lookup table is keyed on. The API does not know what a rate centre is; it tells you the geographic identifiers, and your table tells you what they mean for regulatory billing and PSAP routing.

What is a realistic cache hit rate for an outage classification pipeline? For a utility or telecom network where many events recur at the same fixed infrastructure coordinates (poles, splices, substations), a 60% cache hit rate is a conservative floor. Networks with dense fixed-asset databases often see 75-80% hit rates within the first week of operation. Cache on the rounded coordinate (4 decimal places ≈ 11 m precision) with a TTL of 24 hours for event-driven lookups and 30 days for asset-record lookups.

Related Articles

---

*I.A. / CSV2GEO Creator*

Ready to geocode your addresses?

Use our batch geocoding tool to convert thousands of addresses to coordinates in minutes. Start with 100 free addresses.

Try Batch Geocoding Free →