Looking up county, district, and school zone from an address

Resolve any US or global address to its full administrative hierarchy — county, district, school zone, ward — via one REST call. Patterns for civic apps.

| June 06, 2026
Looking up county, district, and school zone from an address

A constituent files a service request online. Your system records the address. But which county is responsible for the road? Which school district answers the permit application? Which council ward gets the political credit for fixing the pothole? Which special tax district issues the assessment? In most civic platforms, the answer to each of these questions comes from a different lookup in a different database, and they are often maintained separately, often out of sync, and often wrong.

The right architecture is different: one REST call that takes an address and returns the full administrative hierarchy for that point — country, state, county, municipality, ward, school district, fire district, and whatever else sits in the divisional tree. You display, route, or bill based on the response. The canonical lookup is delegated to a service that keeps the boundary data current, and your application is not in the business of maintaining political geometry.

This post covers the Divisions ancestors/children endpoint, shows how to drive it from curl, Python, and Node, explains where it fits inside a real civic platform's data pipeline, and walks through the failure modes your team will hit in production.

Why administrative hierarchy is hard to maintain in-house

Before getting into the API, it is worth being precise about what makes this problem annoying enough that you should not try to solve it with a shapefile you downloaded once and checked into version control.

Boundaries change on political timescales. A council redistricting happens every ten years at the federal level and more often at the local level. School district lines get redrawn when population shifts. Fire protection districts annex territory as suburbs expand. Tribal land boundaries are subject to judicial rulings. Municipal incorporations and dissolutions are continuous. A shapefile that was accurate in 2022 is wrong in meaningful ways by 2026 — quietly wrong, not loudly wrong, which is the worst kind.

Administrative hierarchies are not trees. In the United States, a single parcel can sit inside a county, a municipality, a school district, a fire district, a special assessment district, a congressional district, a state legislative district, and a soil-and-water conservation district simultaneously — and not all of these are nested. School districts cross county lines. Fire districts cross municipal lines. Congressional districts are famously serpentine. A civic platform that assumes a clean nested hierarchy will misroute service requests every time it hits one of the crossings.

The geometry itself is expensive to query at scale. A point-in-polygon query against a national boundary dataset of 50,000+ polygons requires spatial indexing, careful projection handling, and a database that can serve it under load. Teams that build this in-house typically start with a PostGIS table, discover that complex county geometries cause query times to spike unpredictably, add an index, tune the buffer, and six months later still have a system that works 99% of the time and produces silent wrong answers for the other 1%.

Delegating this to an API that is maintained, indexed, and versioned is the correct engineering call for any civic team that is not primarily in the business of maintaining political geometry.

The Divisions endpoint and what it returns

The CSV2GEO Divisions endpoint resolves a coordinate or address to the administrative divisions that contain it, and — depending on the parameters — can return the ancestors above a given division or the children below it. The endpoint family lives at:

GET /api/v1/divisions
GET /api/v1/divisions/ancestors
GET /api/v1/divisions/children

The base call takes a coordinate or a free-text address and returns the set of administrative divisions that contain the point. The ancestors call traverses upward in the hierarchy from a known division identifier. The children call traverses downward.

A basic request for a point in downtown Denver:

curl -G "https://csv2geo.com/api/v1/divisions" \
  --data-urlencode "lat=39.7392" \
  --data-urlencode "lng=-104.9903" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The response structure:

{
  "meta": { "count": 6, "lat": 39.7392, "lng": -104.9903 },
  "divisions": [
    { "id": "div_us_co",           "name": "Colorado",            "level": "state",    "country": "US" },
    { "id": "div_us_co_denver",    "name": "Denver County",       "level": "county",   "country": "US" },
    { "id": "div_us_co_den_city",  "name": "City and County of Denver", "level": "municipality", "country": "US" },
    { "id": "div_us_co_dps",       "name": "Denver Public Schools", "level": "school_district", "country": "US" },
    { "id": "div_us_co_dfd",       "name": "Denver Fire District", "level": "fire_district", "country": "US" },
    { "id": "div_us_cd07",         "name": "Colorado's 7th Congressional District", "level": "congressional_district", "country": "US" }
  ]
}

The level field is the discriminator your routing logic should key on. The id field is stable — you can use it as a foreign key in your own database, and when boundary lines move the API reflects the change without requiring you to update your local geometry.

To pull all ancestors of a known division — useful when you have a ward identifier and need the county and state above it:

curl -G "https://csv2geo.com/api/v1/divisions/ancestors" \
  --data-urlencode "id=div_us_co_den_city" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

To pull all children of a known division — useful when you need to list every school district in a county:

curl -G "https://csv2geo.com/api/v1/divisions/children" \
  --data-urlencode "id=div_us_co_denver" \
  --data-urlencode "api_key=$CSV2GEO_API_KEY"

The children call is what powers the "list all wards in this council district" or "list all precincts in this county" UI that civic platforms need.

A worked example: routing a service request to the right authority

Imagine a 311-style platform that accepts service requests from residents. The form captures: name, phone, address, description, category. The routing logic needs to determine which agency receives the ticket — the county highway department, the city public works team, the school district facilities group, or a special district like a water authority.

The addresses come in as free text. The routing table is keyed on division.id crossed with request.category. Here is how to wire the two together.

Step 1: Geocode the incoming address to coordinates

Always geocode first, then look up divisions. Free-text address matching at the boundary lookup level is fragile — two addresses on either side of a political boundary might be nearly identical strings, and small geocoding errors matter when the boundary is a road centreline.

import os
import requests

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

def geocode(address: str) -> dict | None:
    r = requests.get(
        f"{API}/geocode",
        params={"q": address, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    results = r.json().get("results", [])
    if not results:
        return None
    top = results[0]
    # Confidence below 0.7 warrants a manual review flag.
    if top.get("confidence", 0) < 0.7:
        return {"lat": top["lat"], "lng": top["lng"], "low_confidence": True}
    return {"lat": top["lat"], "lng": top["lng"], "low_confidence": False}

Step 2: Look up the administrative divisions for the geocoded point

def get_divisions(lat: float, lng: float) -> list[dict]:
    r = requests.get(
        f"{API}/divisions",
        params={"lat": lat, "lng": lng, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    return r.json().get("divisions", [])

The return value is a list of division objects. Index them by level for downstream use:

def index_by_level(divisions: list[dict]) -> dict[str, dict]:
    return {d["level"]: d for d in divisions}

Step 3: Match the request category to the responsible authority

Your routing table is a mapping of (division_id, request_category) to a queue or an email. The division lookup gives you the IDs; your routing table tells you who owns the work.

ROUTING = {
    ("div_us_co_denver",    "pothole"):          "denver_public_works",
    ("div_us_co_den_city",  "sidewalk"):         "denver_public_works",
    ("div_us_co_dps",       "school_crosswalk"): "dps_facilities",
    ("div_us_co_dfd",       "fire_hydrant"):     "denver_fire",
}

def route_request(divisions_by_level: dict, category: str) -> str | None:
    for div in divisions_by_level.values():
        key = (div["id"], category)
        if key in ROUTING:
            return ROUTING[key]
    return None  # Falls to manual triage.

Step 4: Persist the division IDs alongside the service request

Do not just persist the division names — persist the id values. Names change (Denver renames a department; a district rebrands). IDs are stable. When you pull a service request report three years later and ask "how many pothole requests came from Denver County in Q3 2024", the join on division_id still works. A join on the string "Denver County" will miss everything filed before the rename.

def persist_request(db, request_id, geocode_result, divisions, category, route):
    db.execute(
        """
        INSERT INTO service_requests
          (id, lat, lng, low_confidence, county_div_id, municipality_div_id,
           school_district_div_id, category, routed_to)
        VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
        """,
        (
            request_id,
            geocode_result["lat"],
            geocode_result["lng"],
            geocode_result["low_confidence"],
            divisions.get("county", {}).get("id"),
            divisions.get("municipality", {}).get("id"),
            divisions.get("school_district", {}).get("id"),
            category,
            route,
        ),
    )

Step 5: Surface the hierarchy to the constituent in the confirmation UI

A confirmation screen that says "Your request has been routed to the responsible authority" is opaque. A screen that says "Your request is in Denver County / City and County of Denver, routed to the Denver Public Works team" builds appropriate trust. You have all the data to produce this from the division response.

// Node — building the display string from a division lookup
const API = 'https://csv2geo.com/api/v1';
const KEY = process.env.CSV2GEO_API_KEY;

async function getDivisions(lat, lng) {
  const url = `${API}/divisions?lat=${lat}&lng=${lng}&api_key=${KEY}`;
  const r = await fetch(url);
  if (!r.ok) throw new Error(`divisions ${r.status}`);
  const body = await r.json();
  return body.divisions ?? [];
}

function buildConfirmationString(divisions) {
  const county = divisions.find(d => d.level === 'county');
  const muni   = divisions.find(d => d.level === 'municipality');
  if (!county) return 'jurisdiction confirmed';
  return muni
    ? `${muni.name}, ${county.name}`
    : county.name;
}

The ancestors call: traversing upward from a known division

Sometimes you already know the division — a ward identifier from a legacy database, a school district code from a state education registry — and you need to know what sits above it. The /divisions/ancestors endpoint is the right tool.

A concrete government use case: a state agency issues grants to school districts. The grant management system needs to know which county each school district falls within, for aggregate reporting. The agency has a list of school district IDs. The ancestors call resolves each one to its county and state without requiring the agency to maintain a district-to-county crosswalk that goes stale every redistricting cycle.

def get_ancestors(division_id: str) -> list[dict]:
    r = requests.get(
        f"{API}/divisions/ancestors",
        params={"id": division_id, "api_key": KEY},
        timeout=15,
    )
    r.raise_for_status()
    return r.json().get("ancestors", [])

# Usage: for each school district in the grant list
for district_id in district_ids:
    ancestors = get_ancestors(district_id)
    county = next((a for a in ancestors if a["level"] == "county"), None)
    print(f"{district_id} → county: {county['name'] if county else 'unknown'}")

The children call: traversing downward for enumeration

A county election commission needs to list every precinct in its jurisdiction. A public health authority needs to enumerate every census tract in a district for outbreak reporting. A school board needs to list every ward it covers for board-seat allocation. The /divisions/children call handles all three patterns.

def get_children(division_id: str, level_filter: str | None = None) -> list[dict]:
    params = {"id": division_id, "api_key": KEY}
    if level_filter:
        params["level"] = level_filter
    r = requests.get(
        f"{API}/divisions/children",
        params=params,
        timeout=15,
    )
    r.raise_for_status()
    return r.json().get("children", [])

# List all school districts in a county
school_districts = get_children("div_us_co_denver", level_filter="school_district")
for sd in school_districts:
    print(sd["id"], sd["name"])

The level_filter parameter keeps the response focused when the children response would otherwise include many different administrative types — municipalities, fire districts, school districts, and wards may all be children of the same county, and you typically want only one type per query.

Production failure modes

Four things that bite civic engineering teams who treat this as a simple lookup.

Addresses on the boundary. A property on a county line can produce different division sets depending on whether the geocoder places it 10 m east or 10 m west. In practice this affects a small fraction of addresses, but in civic routing the consequence is a misdirected service request. The mitigation: for addresses with geocoding confidence below 0.8, flag them for manual jurisdiction confirmation before routing. Store the low_confidence boolean — see Geocoding Confidence Scores Explained for the full treatment.

Special districts that cross normal hierarchy lines. A water authority district may span three counties. A regional transit district may span two states. These will appear in the divisions response as separate entries that do not fit neatly into the county → municipality → ward tree. Your routing table must handle them as first-class entries, not as outliers. Do not assume a clean hierarchy.

Historical requests re-resolved against current boundaries. A service request from 2019 was filed against a ward that was subsequently redrawn in a 2021 redistricting. If you re-run the division lookup against the original coordinates today, you will get the current boundaries, not the 2019 boundaries. For audit purposes this matters. The mitigation is to freeze the division IDs at request creation time — do not re-resolve, rely on the stored IDs. The API gives you the current answer; your database preserves the historical one.

Rate limiting during bulk ingestion. If you are backfilling historical records — say, appending division IDs to a decade of 311 requests — you will hit rate limiting. Implement a concurrency cap and exponential backoff. See Exponential Backoff — When to Retry, When to Stop and Concurrency Tuning — Geocoding Sweet Spot for the right retry posture. Do not parallelize to 100 workers and watch the 429s pile up.

Caching the division lookup

Division boundaries change on political timescales — redistricting cycles measured in years, not days. This means you can cache aggressively. A division lookup result is valid for months. The practical caching strategy:

  • Primary cache key: sha256(round(lat, 5) + "," + round(lng, 5)) — this clusters near-identical coordinates to the same cache entry.
  • TTL: 90 days minimum. For most civic platforms, 365 days is defensible.
  • Invalidation event: subscribe to a redistricting notification — your state or county election authority publishes redistricting calendars. On a redistricting event, flush the affected region's cache entries.

The Caching Geocoding Results — 90% Cost Reduction post covers the Redis-backed caching pattern in detail. The same pattern applies verbatim to the divisions endpoint.

A rough cost model: a 311 platform receiving 5,000 service requests per day, with a 90-day cache and a cache hit rate of ~60% (some addresses recur; most are unique), consumes roughly 2,000 fresh division lookups per day. At free-tier limits of 3,000 calls per day — no card required — a mid-size municipal 311 platform can run at zero cost. A large metro that exceeds the free tier sits well within the entry paid bracket of $54/month for 100,000 calls.

What the divisions API does not do

Honest scope matters for civic procurement reviews.

It does not give you the legal boundary geometry. The endpoint returns id, name, and level for each division — it does not return the polygon coordinates. For a GIS map view that draws the boundary on screen, you need either a geometry endpoint or a separately licensed boundary dataset. The divisions API is a routing and classification tool, not a tile server.

It does not carry sub-precinct data everywhere. Coverage depth varies by country and administrative tier. In the US, coverage runs deep — county, municipality, school district, congressional district, state legislative district. In smaller or less-documented administrative systems in some of the 39 countries covered, the tree may stop at state or county level. Query the response's level fields to understand what you have before routing against it.

It does not reflect same-day boundary changes. Political geometry updates propagate on a publishing schedule, not in real time. A boundary change ratified today will typically appear in the API within days to weeks. For the vast majority of civic applications, this lag is irrelevant. For voter registration systems that need to reflect a redistricting on its effective date, confirm the propagation schedule before committing to a go-live date.

Observability in a civic pipeline

A civic platform that silently routes service requests to the wrong authority is worse than one that fails loudly. Build the following metrics from day one:

  • `division_lookup_confidence_p50` / `_p95`: if geocoding confidence starts dropping, routing accuracy drops with it.
  • `division_lookup_null_rate`: what fraction of addresses return a null division for county? Should be under 0.5% for a clean US address list.
  • `routing_fallback_rate`: how often does the routing logic hit the "falls to manual triage" branch? If this exceeds 2%, your routing table is incomplete.
  • `cache_hit_rate`: should be above 50% within a month on any active platform. If it is not, your cache key is too narrow.

The Observability for Geocoding Pipelines post covers the full instrumentation pattern for this class of pipeline.

Frequently Asked Questions

Which countries does the Divisions endpoint cover? The endpoint draws on the same 39-country coverage as the broader CSV2GEO platform. Depth varies — US coverage is the most detailed, including school districts, fire districts, and congressional districts. For most other countries, coverage runs to state/province and county or equivalent. Check the response level fields to understand what is available for your target country before building routing logic that depends on a specific level.

Can I use the division ID as a stable foreign key in my database? Yes. Division IDs are stable across boundary updates. When a boundary moves, the name or geometry may change but the ID does not. This is intentional — it is the correct design for civic systems that need historical consistency.

What happens when an address is genuinely on or near a boundary? The API returns the division that contains the geocoded point. For points very close to a boundary, the result reflects the best-available geocoded position. Flag low-confidence geocodes (below 0.8) for manual jurisdiction confirmation rather than trusting the automated routing.

Does the children endpoint return all nested levels at once, or only the immediate children? By default it returns immediate children only — one level down from the queried division. Use the level_filter parameter to request a specific administrative level, which may skip intermediate levels if needed. For deep traversal, iterate: get the children, then call children again for each result.

Is there a batch endpoint for resolving many addresses to their divisions at once? The geocoding endpoint accepts address batches, and you can chain the output coordinates into multiple parallel divisions calls. There is not a single endpoint that accepts 500 addresses and returns 500 division sets in one call. In practice, parallelising to 5-10 concurrent divisions calls handles most bulk enrichment workloads without hitting rate limits.

How should a civic platform handle an address that returns no county-level division? Route it to a manual triage queue, not to a default authority. Silently routing a no-county-match to "the nearest county" is likely wrong and legally problematic. Flag it, notify a human reviewer, and hold the service request open until the jurisdiction is confirmed. Log the lat/lng and geocoding confidence alongside the flag — that data will tell you if a systematic geocoding problem is producing the nulls.

Can I use the Divisions endpoint for voter registration verification? The endpoint will tell you which congressional and state legislative districts a point falls within, which is the core of a residency verification check. However, voter registration carries legal obligations around data currency — confirm the redistricting propagation schedule with the CSV2GEO team and ensure your application has a documented process for handling boundary-change lag before deploying this in a registration context.

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 →