Automating shapefile and GeoJSON exports from municipal portals

A county planning portal exposes a “Download zoning overlay” button that, behind the scenes, fires an asynchronous export job, stages a compressed Shapefile or GeoJSON archive, and hands back a one-time download token. You need an unattended job that does the same thing every night — fetch the export, confirm it is intact, reproject it, normalize its attributes, and swap it into your parcel fabric — without a missing .prj file or a truncated DBF column silently corrupting downstream spatial joins. This page answers that one narrow question: how do you turn a flaky, token-gated municipal export endpoint into a deterministic, recoverable ingestion step? It is a concrete application of GIS export sync workflows, focused on the exact moment a portal generates an on-demand archive and your automation has to trust it. The most frequent failure mode is the transition between legacy State Plane Shapefiles and WGS84 GeoJSON, where projection drift, attribute truncation, and invalid geometries break parcel matching with no exception at all.

Diagnosis: identifying where the export step silently corrupts data jump to heading

Municipal export endpoints behave like state machines, not synchronous data pipes, and they fail in three recognizable ways. Learn to spot all three before writing fetch code.

What the 10-character DBF ceiling does to eight real field names A matrix of eight canonical field names against the name each one gets inside a shapefile DBF header, whether that truncated name collides with another field, and what the writer does about it. zoning_code_current and zoning_code_previous both truncate to ZONING_COD, and effective_date and effective_date_source both truncate to EFFECTIVE_, so each pair collides and one member of each pair is silently renamed. overlay_district_1 and overlay_district_2 truncate to OVERLAY_DI and also collide. parcel_identifier and parcel_id_source truncate to PARCEL_IDE and PARCEL_ID_ and survive. What the 10-character DBF ceiling does to eight real field names name in the DBF header collides with another field? writer behaviour zoning_code_current ZONING_COD collides silently renamed zoning_code_previous ZONING_COD collides silently renamed effective_date EFFECTIVE_ collides silently renamed effective_date_source EFFECTIVE_ collides silently renamed overlay_district_1 OVERLAY_DI collides silently renamed overlay_district_2 OVERLAY_DI collides silently renamed parcel_identifier PARCEL_IDE unique kept parcel_id_source PARCEL_ID_ unique kept survives the write collides — renamed without an error
Truncation is computed here by the same rule the writer uses — first ten characters, upper-cased — and three of the four pairs collide. The writer does not fail; it renames the second field to something like ZONING_C_1, so the export looks fine and the column that says whether a zoning code is current or previous is the one that quietly moves.
  1. The export token expires or the archive arrives partial. A POST to the overlay endpoint returns 202 Accepted with a job token; you poll, download too early, and unzip a truncated file. The symptom is a zipfile.BadZipFile or a Shapefile missing its companions:

    Traceback (most recent call last):
      File "/opt/pipeline/fetch.py", line 44, in fetch_municipal_export
        with zipfile.ZipFile(archive_path, "r") as z:
    zipfile.BadZipFile: File is not a zip file
    
  2. Silent projection drift. The portal exports EPSG:2263 (NY State Plane, feet) or EPSG:26918 (NAD83 / UTM 18N), but the .prj is missing or carries malformed Well-Known Text. geopandas reads crs=None, and a later serialization step raises — or worse, writes coordinates in feet into a layer everything else assumes is WGS84:

    pyproj.exceptions.CRSError: Invalid projection: unknown
    
  3. Attribute truncation and type drift. Shapefiles cap field names at 10 characters and strings at 254. The same field arrives as "ZONE_CD": "R-1" from the DBF and "ZONE_CD": 1 from a later GeoJSON export. No exception fires; a downstream parcel-matching filter just returns zero rows.

Profile the archive before trusting it. List the Shapefile companions and read the declared CRS without loading the full geometry set, so you can fail fast on a structurally broken export:

import fiona
from pathlib import Path

REQUIRED_SIDECARS = {".shp", ".shx", ".dbf", ".prj"}

def inspect_export(workspace: Path) -> None:
    shp = next(workspace.glob("*.shp"), None)
    if shp is None:
        raise FileNotFoundError("No .shp in archive; portal may have exported GeoJSON only.")
    present = {p.suffix.lower() for p in workspace.glob(f"{shp.stem}.*")}
    missing = REQUIRED_SIDECARS - present
    if missing:
        raise ValueError(f"Shapefile incomplete; missing {sorted(missing)}.")
    with fiona.open(shp) as src:
        print(f"{shp.name}: crs={src.crs}, features={len(src)}, fields={list(src.schema['properties'])}")

A missing .prj in that output is your cue to halt before transformation, not after. This is the same fail-fast discipline that GIS export sync workflows apply at every staging boundary.

Asynchronous municipal export lifecycle The client POSTs the overlay endpoint and receives 202 Accepted with a job token and status URL. A GET status poll loops while the job reports PENDING or RUNNING, sleeping with jittered exponential backoff between attempts. On COMPLETED the portal returns a one-time download URL; the archive is streamed, its checksum validated, and extracted into an isolated workspace as the terminal good state. A timeout, a FAILED job, or a BadZipFile routes to a branch that re-requests the token for transient faults or aborts and quarantines for structural ones. ASYNCHRONOUS MUNICIPAL EXPORT LIFECYCLE PENDING / RUNNING · back off + jitter, re-poll POST overlay → 202 Accepted token + status_url GET status poll endpoint cap 30s sleep COMPLETED one-time download_url Stream archive validate checksum Extract isolated workspace TIMEOUT · FAILED · BadZipFile transient → re-request token · structural → abort + quarantine timed out bad zip

Step-by-step implementation jump to heading

Each step isolates exactly one concern. Compose them into the orchestrator at the end.

Step 1 — Poll the export token with bounded exponential backoff jump to heading

Treat the download phase as a transactional boundary. Poll the status endpoint with jittered, capped backoff so you stage the archive only once the job reports COMPLETED, and never hammer a portal hard enough to trip its WAF. Aggressive polling here is exactly what municipal API rate limit management exists to prevent.

import time
import zipfile
import requests
from pathlib import Path

def fetch_municipal_export(status_url: str, token: str, max_retries: int = 12) -> Path:
    workspace = Path(f"/tmp/muni_export_{token}")
    workspace.mkdir(parents=True, exist_ok=True)

    for attempt in range(max_retries):
        status_resp = requests.get(status_url, params={"token": token}, timeout=15)
        status_resp.raise_for_status()
        payload = status_resp.json()

        if payload.get("state") == "COMPLETED":
            archive_resp = requests.get(payload["download_url"], stream=True, timeout=60)
            archive_resp.raise_for_status()
            archive_path = workspace / "export.zip"
            with open(archive_path, "wb") as f:
                for chunk in archive_resp.iter_content(chunk_size=8192):
                    f.write(chunk)
            with zipfile.ZipFile(archive_path, "r") as z:
                z.extractall(workspace)
            return workspace

        # Jittered exponential backoff, capped to avoid WAF blocks.
        time.sleep(min(2 ** attempt + (attempt * 0.5), 30))

    raise RuntimeError("Export polling timed out; endpoint may be rate-limited or offline.")

Step 2 — Enforce and validate the CRS before any transformation jump to heading

Implicit CRS inference is an anti-pattern. Reject a layer with no declared projection, catch coordinates that exceed geographic bounds (the signature of projected meters or feet masquerading as degrees), and only then reproject. Strict, explicit projection handling is the cross-jurisdiction discipline detailed in CRS alignment strategies.

import geopandas as gpd
from shapely.validation import make_valid

def enforce_and_validate_crs(gdf: gpd.GeoDataFrame, expected_epsg: int = 4326) -> gpd.GeoDataFrame:
    if gdf.crs is None:
        raise ValueError("Source lacks .prj metadata; manual CRS assignment required.")

    bounds = gdf.total_bounds  # (minx, miny, maxx, maxy)
    if abs(bounds[0]) > 180 or abs(bounds[1]) > 90:
        raise ValueError("Coordinates exceed geographic bounds; verify CRS before transforming.")

    if gdf.crs.to_epsg() != expected_epsg:
        gdf = gdf.to_crs(epsg=expected_epsg)

    # Repair self-intersections and ring-orientation faults before serialization.
    gdf["geometry"] = gdf["geometry"].apply(make_valid)
    if not gdf["geometry"].is_valid.all():
        raise ValueError("Invalid geometries persist after repair; abort to protect spatial joins.")

    return gdf

This guard prevents the RuntimeError: Cannot transform geometry with unknown CRS that routinely halts unmonitored ingestion, and it stops a feet-based State Plane export from being written as if it were degrees.

Step 3 — Normalize attributes against a canonical schema jump to heading

Map the export’s columns onto a fixed canonical schema, cast every field to a known type, and replace nulls explicitly rather than letting NaN poison a dtype. This is the same contract enforced by attribute normalization, applied at the export boundary so type drift never reaches the parcel index.

CANONICAL_SCHEMA = {
    "ZONE_CD": str,
    "LAND_USE": str,
    "ACRES": float,
    "LAST_UPD": str,
}

def normalize_attributes(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    for col, dtype in CANONICAL_SCHEMA.items():
        if col in gdf.columns:
            gdf[col] = gdf[col].astype(dtype)
            if dtype is str:
                gdf[col] = gdf[col].str.strip().replace("", "UNKNOWN")
            elif dtype is float:
                gdf[col] = gdf[col].fillna(0.0)
        else:
            gdf[col] = "UNKNOWN" if dtype is str else 0.0  # absent column, explicit default
    return gdf

Step 4 — Serialize and emit a compliance manifest under a two-phase commit jump to heading

Never overwrite production in place. Stage the validated GeoJSON in a working directory, generate the manifest, and only swap pointers once everything passes. The manifest is the audit artifact that downstream schema validation & data quality checks consume.

import hashlib
import json
from datetime import datetime, timezone

def serialize_and_manifest(gdf: gpd.GeoDataFrame, token: str, staging_dir: Path) -> Path:
    staging_dir.mkdir(parents=True, exist_ok=True)
    ts = datetime.now(timezone.utc).strftime("%Y%m%d")
    geojson_path = staging_dir / f"zoning_{token}_{ts}.geojson"
    gdf.to_file(geojson_path, driver="GeoJSON")

    manifest = {
        "pipeline_id": token,
        "timestamp": datetime.now(timezone.utc).isoformat(),
        "source_crs": gdf.crs.to_epsg(),
        "feature_count": len(gdf),
        "geometry_valid_count": int(gdf["geometry"].is_valid.sum()),
        "output_checksum": hashlib.sha256(geojson_path.read_bytes()).hexdigest(),
        "schema_version": "1.2.0",
    }
    (staging_dir / f"manifest_{token}.json").write_text(json.dumps(manifest, indent=2))
    return geojson_path

Step 5 — Compose the recoverable orchestrator jump to heading

Wire the steps into a single function whose every stage either advances cleanly or raises before touching production.

def run_zoning_sync_pipeline(status_url: str, token: str, staging_dir: Path) -> None:
    workspace = fetch_municipal_export(status_url, token)   # Phase 1: fetch & extract
    inspect_export(workspace)                               # fail fast on broken archives
    shp_path = next(workspace.glob("*.shp"))

    gdf = gpd.read_file(shp_path)                           # Phase 2: load & validate
    gdf = enforce_and_validate_crs(gdf, expected_epsg=4326)
    gdf = normalize_attributes(gdf)

    serialize_and_manifest(gdf, token, staging_dir)        # Phase 3: stage & manifest
    print(f"Sync staged at {staging_dir}; promote only after verification passes.")

Verification & testing jump to heading

Confirm the export actually round-tripped without silent loss before promoting it.

A shapefile is five files, and only three of them are mandatory Five stacked file cards make up a shapefile export. The .shp file holds the geometry, the .shx file the record index and the .dbf file the attributes; these three are mandatory and a reader fails outright without them. The .prj file holds the coordinate reference system and the .cpg file the attribute encoding; both are optional, and when either is missing the reader does not fail. Instead it assumes a default, so coordinates are interpreted in the wrong projection and non-ASCII characters in street names are mangled. The card for each optional file states what silently goes wrong when it is absent. Mandatory — a missing one fails loudly, which is the good case parcels.shp geometry records reader raises without it parcels.shx record offset index reader raises without it parcels.dbf attribute table (10-char names) reader raises without it Optional — a missing one fails silently, which is the dangerous case parcels.prj the coordinate reference system, as ESRI WKT1 Absent: the reader assumes a default or None. Coordinates load, overlays land in the wrong place. parcels.cpg the code page for the attribute table Absent: the reader guesses an encoding. Street names with accents arrive mangled. An export job must therefore assert on the file SET, not on the .shp alone — and a consumer that receives a zip without a .prj should reject it rather than assume the projection it happens to expect.
The two optional members are the ones that matter for correctness. Dropping .shp raises immediately; dropping .prj produces a file that loads fine and puts every parcel in the wrong place — so the export check has to verify the whole set, and the zip you publish should be rejected by consumers if the projection is missing.
  • Bounding box lands in jurisdiction. After reprojection, assert the transformed total_bounds falls inside the municipality’s known extent. A box centered off the coast of Africa (0, 0) means a CRS was assigned, not transformed.
  • Geometry validity is total. assert gdf["geometry"].is_valid.all() — the manifest’s geometry_valid_count must equal feature_count. A shortfall means make_valid could not repair a topology and the layer is unsafe to join.
  • Schema conforms exactly. assert set(CANONICAL_SCHEMA).issubset(gdf.columns) and check each dtype; an object column where you expect float64 means a string escaped Step 3.
  • GeoJSON parses as RFC 7946. Reload the staged file with gpd.read_file and confirm the feature count matches; this catches encoding or driver faults that to_file swallowed. The serialized output must conform to RFC 7946: The GeoJSON Format, which mandates WGS84 (EPSG:4326).
  • Checksum is deterministic. Re-running the same source export must reproduce the manifest output_checksum. Drift signals non-deterministic feature ordering — sort before serializing.

A quick reference for the projections you will meet most often at the export boundary:

EPSG Datum / system Units Typical use
4326 WGS84 geographic degrees GeoJSON target, web tiles
2263 NY State Plane, Long Island US feet NYC zoning exports
26918 NAD83 / UTM Zone 18N meters Mid-Atlantic county Shapefiles
3857 WGS84 Web Mercator meters Slippy-map overlays

Failure recovery jump to heading

When a stage fails mid-run, keep production untouched and stay reproducible.

  • A failed sync leaves zero partial state. Because Step 4 stages to a working directory and the pointer swap is the last action, an exception anywhere upstream simply leaves the last known good layer live. Retain the raw archive and workspace for forensic review instead of deleting on error.
  • Retry the fetch, abort the parse. A timed-out token or BadZipFile is transient — re-request a fresh export token through your error handling & retry logic. A CRS or schema exception is structural; do not retry it, quarantine the archive and emit a structured alert so a human reviews the municipal change.
  • Quarantine breaking schema changes. When a portal drops ZONE_CD or renames a field, halt that one source, write the archive and its manifest to a review_required/ partition, and let sibling jurisdictions continue. Never let one municipality’s redesign abort the whole sync.
  • Parallelize without losing isolation. When syncing many portals on a schedule, run each through async batch processing so a single slow endpoint never blocks the others, while each export keeps its own isolated workspace and two-phase commit.

Frequently asked questions jump to heading

The portal only exports GeoJSON, never Shapefile — does any of this change?

The fetch, CRS validation, normalization, and two-phase commit are identical; you simply skip the Shapefile companion check in inspect_export. Watch for the opposite failure mode: GeoJSON has no field-length limit, so type drift (a code arriving as "R-1" one day and 1 the next) is more common than truncation. Step 3’s explicit casting is what protects you.

Why insist on EPSG:4326 as the target instead of keeping the source projection?

RFC 7946 GeoJSON mandates WGS84, and a single canonical CRS is what makes cross-jurisdiction spatial joins correct. Reprojecting once, at the export boundary, means every downstream consumer can assume degrees. If you need a projected CRS for area calculations, derive it on demand rather than storing the fabric in mixed projections.

How should I handle a missing or malformed .prj file?

Halt — do not guess. An incorrect manual CRS assignment is worse than a failed run because it corrupts coordinates silently. Quarantine the archive, alert, and only assign a CRS manually once you have confirmed the source projection against the municipality’s published metadata. Treat the corrected projection as a per-source config entry so the next run is automatic.

Can I poll the export endpoint more aggressively to shorten the sync window?

No. Municipal portals enforce undocumented rate limits and WAF rules; tight polling gets your job IP-blocked, which is far costlier than a slightly longer window. Keep the capped exponential backoff with jitter, and if you run many portals, stagger their schedules rather than increasing per-portal poll frequency.