Automating US County Boundary Extraction with OSMnx

TopologyException: side location conflict and empty GeoDataFrame returns are the two outputs that quietly break automated US county extraction with OSMnx — and both surface at the boundary-ingestion stage, before any siting, interconnection-queue, or setback math ever runs. OSMnx is architecturally optimized for street-network topology, but its geocode_to_gdf() helper is routinely repurposed to pull boundary=administrative polygons for energy-GIS work. At national scale that pattern triggers Nominatim rate limits, shapely topology exceptions, and silent CRS mismatches that corrupt downstream spatial joins and permitting calculations. This page is part of the Regulatory Boundary Mapping workflow within Core Energy-GIS Data & Spatial Fundamentals; it resolves each failure mode with root-cause mitigation, a pre-flight check, memory-aware batching, and authoritative fallback routing so the geometry entering your compliance mask is deterministic and audit-ready.

Root-Cause Analysis: Why OSMnx Fails on County Boundaries

OSMnx queries OpenStreetMap’s Nominatim geocoder, which returns community-edited boundary=administrative features. Three compounding causes dominate county-level extraction failures in energy pipelines, and none of them reliably raise on the first call:

  1. Ambiguous place queries. Nominatim returns multiple matches for generic county names ("Washington County" resolves in 30+ states). Without an explicit state qualifier and a deterministic which_result, OSMnx silently returns the first hit or an empty GeoDataFrame, contaminating siting models with the wrong polygon.
  2. Topology exceptions. OSM administrative rings frequently contain self-intersections, duplicate nodes, and sliver polygons from edits. Passed to geopandas.overlay() or shapely.intersection(), these raise TopologyException: side location conflict and halt automated corridor or exclusion modeling.
  3. CRS and area distortion. OSMnx returns EPSG:4326 (WGS84) by default. Computing acreage, setback buffers, or transmission right-of-way zones in unprojected degrees yields mathematically invalid results — degrees are not meters. This is the same projection-drift hazard covered under coordinate reference systems for energy projects.

The diagram below maps each cause to the fix stage that neutralizes it.

How each county-extraction failure mode maps to the stage that neutralises it A query of county name plus state flows into a Nominatim geocode decision. On yes it reaches a topology-repairable decision; on no it routes down to a fallback box for US Census TIGER/Line or a state GIS portal. The topology decision routes yes to a "Project to EPSG:5070" step and no down to the same fallback. The projection step and the fallback both converge on a validated county GeoDataFrame. From raw query to audit-ready geometry: two gates, one fallback yes yes no no County name + state Project to EPSG:5070 Nominatim geocode ok? Topology repairable? Fallback: TIGER/Line or state GIS portal Validated county GeoDataFrame

Pre-flight Validation: Surface the Root Cause Before Overlay

Run a cheap geocode probe before committing the polygon to your pipeline. This function surfaces ambiguity, emptiness, and invalidity up front, so the fault is logged at ingestion rather than masked as a vanished constraint three stages later.

An unclosed relation ring, and what closing it costs On the left, a county outline drawn as five way segments with one segment missing, leaving a visible gap; the resulting polygonisation is marked as invalid with an area of zero. In the middle, the repair: the gap is bridged, the ring closes, and make_valid returns a single valid polygon. On the right, three consequences of skipping the repair — a dropped county, a wrongly clipped parcel set, and a setback overlay that silently reports no constraints. A relation is a set of ways — nothing guarantees they close relation as extracted one way missing — ring never closes polygonise() → invalid · area 0 ring closed, then make_valid() one valid polygon · area defensible County silently dropped Parcels clipped to nothing Overlay reports no constraint
python
import osmnx as ox
import geopandas as gpd


def preflight_county(query: str, state_abbr: str) -> dict:
    """Probe a county query and report the failure mode without raising mid-pipeline."""
    full_query = f"{query} County, {state_abbr}, USA"
    report = {"query": full_query, "ok": False, "reason": None, "n_features": 0}

    try:
        candidate = ox.geocode_to_gdf(full_query, which_result=1)
    except Exception as exc:  # Nominatim miss, 429, or network fault
        report["reason"] = f"geocode_failed: {exc}"
        return report

    report["n_features"] = len(candidate)
    if candidate.empty:
        report["reason"] = "empty_result: no admin boundary matched the query"
        return report
    if not candidate.geometry.is_valid.all():
        report["reason"] = "invalid_topology: self-intersection or sliver present"
        return report

    report["ok"] = True
    return report

A clean run returns {"ok": True, ...}; anything else is a flag to route to a fallback source or tighten the query before the geometry pollutes a spatial join.

Fix Implementation: Deterministic Extraction with Topology Repair

The corrected extractor replaces fragile single-call logic with an explicit state-qualified query, make_valid topology repair, an empty-geometry guard, and an equal-area projection. EPSG:5070 (CONUS Albers Equal Area) is chosen deliberately: acreage and metric setbacks demand an equal-area projected CRS, never the source EPSG:4326.

python
import osmnx as ox
import geopandas as gpd
from shapely.validation import make_valid
import warnings

warnings.filterwarnings("ignore", category=RuntimeWarning)


def extract_validated_county(query: str, state_abbr: str, target_epsg: str = "EPSG:5070") -> gpd.GeoDataFrame:
    """Extract county geometry with deterministic query, topology repair, and equal-area projection."""
    # 1. Deterministic Nominatim query with state qualifier
    full_query = f"{query} County, {state_abbr}, USA"
    county_gdf = ox.geocode_to_gdf(full_query, which_result=1)

    # 2. Topology validation & repair (resolves side-location conflicts)
    county_gdf["geometry"] = county_gdf["geometry"].apply(make_valid)

    # 3. Drop invalid/empty geometries before projecting
    valid_mask = county_gdf.geometry.is_valid & ~county_gdf.geometry.is_empty
    county_gdf = county_gdf[valid_mask].copy()
    if county_gdf.empty:
        raise ValueError(f"No valid geometry returned for {full_query}.")

    # 4. Project to CONUS Albers Equal Area for accurate acreage/buffer math
    return county_gdf.to_crs(target_epsg)

Validate the area before trusting the polygon. Convert the projected area in square meters to acres with the exact survey-acre constant:

python
county_gdf = extract_validated_county("Riverside", "CA")
acres = county_gdf.area.sum() / 4046.8564224

Cross-check acres against the US Census reference for that FIPS code; a double-digit deviation almost always means the CRS step was skipped or the wrong polygon was geocoded. For systematic geometry checks across a batch, fold these assertions into your spatial data quality validation gates.

Fallback Routing & Performance Tuning

Batch extraction across 3,000+ counties requires chunked requests, aggressive caching, and strict memory control. Unmanaged loops trigger Nominatim 429 bans and exhaust RAM during geometry serialization.

Choosing a county boundary source: three trade-offs, no free option A comparison of OpenStreetMap, Census TIGER/Line, and state GIS portals across four rows. Currency: OSM is updated continuously, TIGER annually, state portals on their own schedule. Topology: OSM relations frequently fail to close, TIGER is topologically clean, state portals vary. Attributes: OSM carries rich but inconsistent tags, TIGER carries stable FIPS codes, state portals carry local statutory fields. Licence: OSM is share-alike under ODbL, TIGER is public domain, state portals vary by state. No single boundary source is both current and topologically safe OpenStreetMap Census TIGER/Line State GIS portal Currency continuous annual vintage varies by state Topology rings often open clean, validated varies Attributes rich, inconsistent stable FIPS codes statutory fields Licence ODbL share-alike public domain varies by state The workable pattern is OSM for currency, TIGER for the topology check, and the state portal as the tie-break whenever a permitting decision depends on which side of the line a parcel falls.
python
import osmnx as ox
import geopandas as gpd
import pandas as pd
import time
import gc
from pathlib import Path


def batch_extract_counties(county_df: pd.DataFrame, output_dir: Path, chunk_size: int = 50) -> None:
    """Memory-optimized batch extraction with rate-limit compliance and fallback routing."""
    # OSMnx >= 2.0 uses the settings object rather than the removed ox.config()
    ox.settings.log_console = True
    ox.settings.use_cache = True
    ox.settings.cache_folder = str(Path(".osmnx_cache"))

    for i in range(0, len(county_df), chunk_size):
        chunk = county_df.iloc[i:i + chunk_size].copy()
        valid_geoms = []

        for _, row in chunk.iterrows():
            try:
                county_gdf = extract_validated_county(row["county_name"], row["state_abbr"])
                valid_geoms.append(county_gdf)
            except Exception as exc:
                # Route to authoritative TIGER/Line or USGS when OSM is unusable
                # https://www.census.gov/geographies/mapping-files/time-series/geo/tiger-line-file.html
                print(f"[FALLBACK] OSM failed for {row['county_name']}, {row['state_abbr']}: {exc}")
                continue

            time.sleep(1.1)  # Nominatim hard limit: 1 request/second

        if valid_geoms:
            chunk_gdf = gpd.GeoDataFrame(pd.concat(valid_geoms, ignore_index=True))
            chunk_gdf.to_parquet(output_dir / f"chunk_{i:04d}.parquet", index=False)
            del chunk_gdf

        del valid_geoms
        gc.collect()  # Explicit reclamation for large geometry arrays

Apply these strategies when extraction runs at scale or inside CI/CD:

  • Rate-limit compliance. Nominatim enforces 1 request/second; bursts earn IP bans that stall interconnection-queue refreshes. Keep the time.sleep(1.1) throttle, or self-host a Nominatim instance for production volume.
  • Authoritative fallback registry. When OSM topology is irreparable, route to US Census TIGER/Line or a state GIS portal sourced through curated open energy data portals. Maintain a deterministic source registry so every fallback is reproducible.
  • Chunked Parquet writes. geopandas holds geometry arrays in memory until released; windowed writes to GeoParquet prevent OOM crashes over a full national run.
  • Persistent cache. Setting ox.settings.use_cache = True makes re-runs idempotent and removes redundant Nominatim hits — critical for CI gates that re-execute on every commit.
  • Lock the projection once. Reproject at ingestion to EPSG:5070, never per-operation, so buffer and overlay stages never re-trigger an implicit reproject. Centerline inputs that need buffering belong in Grid Capacity Buffer Analysis, not here.

Downstream Validation & Audit Trail

Energy developers and environmental teams need deterministic outputs for regulatory submissions. Gate every extracted batch through an assertion function before it feeds a compliance mask, and tag provenance so a permitting reviewer can trace each geometry to its source.

python
import geopandas as gpd


def audit_county_batch(gdf: gpd.GeoDataFrame, expected_epsg: int = 5070) -> None:
    """CI/CD-safe assertions on an extracted county batch."""
    assert not gdf.empty, "empty batch — all geocodes failed or were dropped"
    assert gdf.crs is not None and gdf.crs.to_epsg() == expected_epsg, \
        f"CRS drift: expected EPSG:{expected_epsg}, got {gdf.crs}"
    assert gdf.geometry.is_valid.all(), "invalid topology survived into the batch"
    assert (gdf.geometry.geom_type.isin(["Polygon", "MultiPolygon"])).all(), \
        "non-areal geometry present — masks require Polygon/MultiPolygon"
    assert (gdf.area > 0).all(), "zero-area geometry indicates a failed projection"

Pair the assertions with provenance metadata on every output row — source="osm_nominatim", extraction_utc, and topology_repaired=True/False — and log the deviation reason, source URL, and a validation checksum whenever a county is served from a fallback dataset. That lineage is what makes the resulting mask defensible in a permitting audit, and it lets the parent Regulatory Boundary Mapping pipeline consume county geometry without ambiguity.

Frequently asked questions

Why does OSMnx return a MultiPolygon for a single county?

Usually because the administrative relation legitimately has more than one part — an exclave, an island, or a detached parcel of jurisdiction — and occasionally because the relation’s ways did not close and the polygonisation produced fragments. The two cases need opposite treatment: keep the genuine multipart geometry, and repair the fragmented one. Distinguish them by area ratio; a genuine exclave is a meaningful fraction of the county, while a fragment from a broken ring is usually a sliver.

Is it safe to cache OSM boundary extracts?

Yes, and it is necessary at any scale — but cache the extract with its query and its fetch date, and treat the cache as a snapshot rather than a source of truth. OSM changes continuously, so two runs weeks apart legitimately disagree. A permitting decision should name the snapshot it used.

What tolerance should the topology repair use?

Small enough that it cannot move a boundary across a parcel, large enough to close the digitising gaps that break rings — a metre or two in a projected metric frame is the usual working range. Express it in metres, never in degrees, and assert the repaired geometry’s area against the original so a “repair” that swallowed a neighbouring polygon fails rather than ships.

External Reference Standards