Municipal Data Structures for Zoning Automation

The single most expensive mistake in automated zoning systems is treating municipal records as a mutable snapshot. A planning department issues a rezoning, your pipeline overwrites the parcel’s zoning_code, and three months later an underwriting team asks what the parcel was zoned on the day a purchase option was signed — and the answer is gone. Worse, the overwrite was a correction the city later retracted, so the “current” row is now wrong with no trail back to the truth. Municipal data structures are the schema-level defense against this class of failure: instead of static lookup tables, production zoning systems model parcels as versioned spatial graphs that record every state transition with both the date a change took effect and the date your system learned about it. This page sits within Municipal Zoning Data Architecture & Compliance Frameworks, and inside that architecture the data structure is the contract that makes point-in-time reconstruction, feasibility modeling against historical code cycles, and audit-ready compliance reporting possible at all. Get the schema wrong and no amount of downstream tooling recovers the lost history.

Versioned parcel store: bi-temporal SCD Type 2 data model Raw municipal payloads land in a JSONB staging schema and pass through a normalization step (CRS alignment, taxonomy resolution, geometry validation) before being written to a versioned parcel store. Inside the store a stable parcel_id fans out to a stack of immutable rows. Each row carries valid time (effective_from to effective_to, the real-world legal timeline) and system time (valid_from to valid_to, the ingestion timeline) plus a GIST-indexed geometry column. The current row v3 is in force with open intervals; v2 is a superseded correction; v1 is retracted but never deleted, only its system-time interval closed. A point-in-time read path selects exactly one active row by containment on both timelines. Every insert, interval closure, and retraction emits an append-only audit record carrying source_hash, run_id, the before and after interval, and a timestamp. Raw municipal payloads portal responses, verbatim Staging schema JSONB, untouched Normalization CRS align · taxonomy geometry validate write path Versioned parcel store · bi-temporal SCD Type 2 parcel_id stable key v3 — in force (current) valid: 2026-04 → ∞ system: 2026-04 → NULL geometry GIST idx v2 — superseded (correction) valid: 2025-01 → 2026-04 system: 2025-01 → 2026-05 geometry GIST idx v1 — retracted, not deleted valid: 2024-06 → 2025-01 system: valid_to closed geometry GIST idx valid time (real-world) system time (ingest) Point-in-time read effective_from ≤ as-of < effective_to valid_from ≤ known < valid_to → exactly one active row as-of every insert / closure / retraction Append-only audit log source_hash · run_id · before/after interval · timestamp

Prerequisites and operational context jump to heading

A versioned municipal store only delivers its guarantees when a few upstream contracts already hold. Before the schema in this page earns its keep, make sure the following are in place:

  • A settled working projection. Every geometry written to the store must already have cleared CRS alignment strategies so it lives in one known, metric-correct CRS. Mixing projections inside a single geometry column corrupts spatial indexes and makes overlay queries silently wrong.
  • A staging layer that preserves raw payloads. The ingestion engine should land untouched municipal responses before normalization. Storing the raw record alongside the normalized row is what lets you replay a correction or reproduce a disputed value months later.
  • A canonical attribute vocabulary. Local zoning codes must resolve through zoning taxonomy mapping before they are queryable across jurisdictions; the schema stores both the raw source code and the canonical category so neither is lost.
  • Validation that runs before insert, not after. Malformed geometries and type violations must be caught by schema validation & data quality checks before they reach the indexed table, because a single invalid polygon can poison a batch upsert and break the GIST index.

With those in place, the data structure can focus on what it does best: recording change without ever destroying it.

Architecture: bi-temporal versioning over a spatial core jump to heading

The design rests on three decoupled but relationally linked concerns — temporal state, spatial geometry, and regulatory attributes — joined through composite keys and spatial indexes. The central idea is bi-temporal modeling. Most teams reach for a single timeline (when did the zoning take effect), but municipal data needs two:

  • Valid time — when a zoning designation was legally in force in the real world (effective_from / effective_to). This is the planning department’s timeline.
  • System time — when your pipeline recorded or corrected the fact (valid_from / valid_to). This is your ingestion timeline.

Separating them is what lets you answer both “what was this parcel zoned on June 1st?” and “what did our system believe it was zoned on June 1st, before the August correction arrived?” — the exact question PropTech audit and litigation support require. Each zoning change is recorded as an immutable row following a Slowly Changing Dimension (SCD Type 2) pattern: nothing is ever updated in place; a state transition closes the prior row’s interval and opens a new one. Point-in-time queries then become interval-containment lookups rather than guesswork.

Concern Columns Index strategy Why it matters
Temporal state valid_from, valid_to, effective_from, effective_to B-tree on the time bounds Enables as-of and as-recorded queries without scanning history
Spatial geometry geometry (single working CRS) GIST Keeps overlay/intersection joins fast and topologically sound
Regulatory attributes zoning_code (raw), internal_category (canonical), jurisdiction_id B-tree composite Lets queries span jurisdictions on the normalized category
Provenance raw_payload (JSONB), source_hash, created_at GIN on JSONB where queried Reproduces corrections and proves what was ingested

The schema below is the production starting point. Note the composite primary key that admits multiple historical rows per parcel and the partial indexes that keep the common “active row” query cheap.

CREATE TABLE zoning_entitlements (
    parcel_id        VARCHAR(32)  NOT NULL,
    zoning_code      VARCHAR(16)  NOT NULL,   -- raw municipal designation
    internal_category VARCHAR(24) NOT NULL,   -- canonical taxonomy value
    jurisdiction_id  VARCHAR(16)  NOT NULL,
    effective_from   TIMESTAMPTZ  NOT NULL,   -- valid time (real-world)
    effective_to     TIMESTAMPTZ,             -- NULL = still in force
    valid_from       TIMESTAMPTZ  NOT NULL DEFAULT NOW(),  -- system time
    valid_to         TIMESTAMPTZ,             -- NULL = current record
    geometry         GEOMETRY(MULTIPOLYGON, 26915),
    raw_payload      JSONB,
    source_hash      CHAR(64)     NOT NULL,   -- SHA-256 of raw payload
    created_at       TIMESTAMPTZ  NOT NULL DEFAULT NOW(),
    PRIMARY KEY (parcel_id, zoning_code, effective_from, valid_from)
);

-- Cheap lookups for the current, in-force state of a parcel.
CREATE INDEX idx_zoning_active
    ON zoning_entitlements (parcel_id)
    WHERE valid_to IS NULL AND effective_to IS NULL;

-- Interval queries across both timelines.
CREATE INDEX idx_zoning_effective ON zoning_entitlements (effective_from, effective_to);
CREATE INDEX idx_zoning_system    ON zoning_entitlements (valid_from, valid_to);

-- Spatial overlay performance.
CREATE INDEX idx_zoning_geom ON zoning_entitlements USING GIST (geometry);

Production implementation jump to heading

Two operations dominate a versioned store: writing a new state transition without corrupting the timeline, and reading the correct slice of history. Both need to be deterministic and idempotent so that re-running a feed never double-writes.

The write path implements SCD Type 2 closure. When a new zoning fact arrives, it must close the open valid-time interval of the superseded row (in the same transaction) before inserting the successor. Idempotency comes from the source_hash: if the incoming payload hashes to a row we already hold, we skip the write entirely.

import hashlib
import json
from datetime import datetime
from psycopg2.extras import execute_values


def payload_hash(raw: dict) -> str:
    """Stable SHA-256 over a canonicalized payload for idempotency + provenance."""
    canonical = json.dumps(raw, sort_keys=True, separators=(",", ":"))
    return hashlib.sha256(canonical.encode("utf-8")).hexdigest()


def apply_zoning_transition(conn, record: dict) -> str:
    """
    Apply one zoning state transition under SCD Type 2 semantics.

    Closes the prior in-force row's valid-time interval and inserts the new
    version atomically. Returns 'inserted', 'skipped' (idempotent), or raises.
    """
    h = payload_hash(record["raw_payload"])

    with conn:                       # transaction: commit on success, rollback on error
        with conn.cursor() as cur:
            # 1. Idempotency guard — have we already stored this exact fact?
            cur.execute(
                "SELECT 1 FROM zoning_entitlements WHERE source_hash = %s LIMIT 1;",
                (h,),
            )
            if cur.fetchone():
                return "skipped"

            # 2. Close the currently in-force row for this parcel (valid time).
            cur.execute(
                """
                UPDATE zoning_entitlements
                   SET effective_to = %(effective_from)s
                 WHERE parcel_id = %(parcel_id)s
                   AND effective_to IS NULL
                   AND valid_to   IS NULL;
                """,
                record,
            )

            # 3. Insert the successor version as a new immutable row.
            cur.execute(
                """
                INSERT INTO zoning_entitlements (
                    parcel_id, zoning_code, internal_category, jurisdiction_id,
                    effective_from, geometry, raw_payload, source_hash
                ) VALUES (
                    %(parcel_id)s, %(zoning_code)s, %(internal_category)s,
                    %(jurisdiction_id)s, %(effective_from)s,
                    ST_GeomFromText(%(geometry_wkt)s, 26915),
                    %(raw_payload)s, %(source_hash)s
                );
                """,
                {**record, "source_hash": h, "raw_payload": json.dumps(record["raw_payload"])},
            )
    return "inserted"

The read path turns a question into an interval-containment query. The function below answers the bi-temporal question precisely: what did the system believe the zoning was, as of a given real-world date and a given knowledge date.

def get_zoning_as_of(conn, parcel_id: str,
                     effective_date: datetime,
                     known_date: datetime | None = None) -> dict | None:
    """
    Bi-temporal point-in-time lookup.

    effective_date — the real-world date whose zoning we want.
    known_date     — restrict to what the system knew as of this date
                     (defaults to "now", i.e. current best knowledge).
    """
    known_date = known_date or datetime.utcnow()
    query = """
        SELECT parcel_id, zoning_code, internal_category,
               ST_AsGeoJSON(geometry) AS geometry, raw_payload
          FROM zoning_entitlements
         WHERE parcel_id = %(pid)s
           AND effective_from <= %(eff)s
           AND (effective_to IS NULL OR effective_to > %(eff)s)
           AND valid_from <= %(known)s
           AND (valid_to IS NULL OR valid_to > %(known)s)
         ORDER BY effective_from DESC, valid_from DESC
         LIMIT 1;
    """
    with conn.cursor() as cur:
        cur.execute(query, {"pid": parcel_id, "eff": effective_date, "known": known_date})
        row = cur.fetchone()
        return dict(zip([c.name for c in cur.description], row)) if row else None

For geometry hygiene, normalize and repair every feature before it reaches the indexed table. Validity must be enforced programmatically — a single self-intersecting polygon will throw during a GIST index build and abort the whole batch.

import geopandas as gpd
from shapely.validation import make_valid

TARGET_CRS = "EPSG:26915"  # NAD83 / UTM Zone 15N — the single working CRS


def normalize_and_validate_layer(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    if gdf.crs is None:
        # Reject rather than guess — an unknown CRS is a quarantine event.
        raise ValueError("GeoDataFrame lacks CRS; route to fallback, do not assume.")

    gdf = gdf.to_crs(TARGET_CRS).copy()

    # Repair self-intersections, slivers, and bad ring orientation.
    gdf["geometry"] = gdf["geometry"].apply(
        lambda g: make_valid(g) if g is not None and not g.is_valid else g
    )
    return gdf.dropna(subset=["geometry"])

For enterprise volumes, push topology repair down into PostGIS with ST_IsValid() and ST_MakeValid() during the upsert rather than holding every geometry in Python memory. The taxonomy resolution itself stays deterministic: a hierarchical lookup that maps raw codes to canonical categories, with unmatched codes flagged rather than guessed.

import pandas as pd


def resolve_zoning_taxonomy(raw_codes: pd.Series, mapping_df: pd.DataFrame) -> pd.Series:
    """Deterministic raw->canonical mapping; unmatched codes flagged for review."""
    merged = raw_codes.to_frame("raw_code").merge(
        mapping_df, left_on="raw_code", right_on="source_code", how="left"
    )
    merged["internal_category"] = merged["internal_category"].fillna("UNMAPPED")
    return merged["internal_category"]

Edge cases and gotchas jump to heading

The schema is straightforward; municipal reality is not. These are the failures that surface in production:

  • Retroactive corrections (the “as-of” trap). A city issues a rezoning dated three months in the past. A single-timeline schema would either overwrite history or refuse the row. Bi-temporal modeling absorbs it: the new row carries the back-dated effective_from while its valid_from is today, so both “what was true” and “what we knew” stay reconstructable.
  • Retractions of corrections. Planning departments sometimes reverse a change. Never delete the retracted row — close its system-time interval (valid_to) and insert the restored version. The audit trail must show the round trip.
  • Split and merge parcels. A parcel subdivides into three, or two combine into one. The parcel_id is no longer 1:1 across the split boundary. Model lineage with a parcel_lineage edge table (parent → child with effective date) so feasibility queries can follow the graph across reapportionment.
  • Topology exceptions on upsert. A ST_MakeValid() repair can change a polygon’s geometry type (a degenerate polygon becomes a GEOMETRYCOLLECTION). Coerce back to MULTIPOLYGON explicitly or the typed geometry column rejects the insert.
  • Composite-key collisions on same-day changes. Two amendments to the same parcel on the same effective_from will collide on the primary key. The valid_from timestamp in the key disambiguates them; never truncate it to a date.
  • JSONB payload drift. Municipal feeds rename fields without warning. Because the raw payload is stored verbatim, a downstream normalization change can be replayed over historical raw rows without re-fetching from the portal — but only if you preserved the payload, which is why the staging contract is non-negotiable.

Integration points jump to heading

The versioned store is both a sink and a source. Upstream, it consumes normalized features from the automated feed ingestion & GIS data parsing pipeline; the ingestion layer’s async batch processing engine is what reprojects and chunks large county feeds into the transition-sized batches this schema writes, and its attribute normalization rules produce the canonical fields the schema stores alongside the raw payload.

Downstream, the immutable history feeds compliance and routing logic. The bi-temporal rows are the substrate for compliance framework integration, which evaluates each state transition against jurisdiction rules, and when a feature arrives malformed or with an unresolved CRS, fallback routing logic decides whether to substitute a secondary source or quarantine the record rather than writing a corrupt version into the timeline.

Compliance and audit artifacts jump to heading

Every write to this store must leave evidence that satisfies PropTech underwriting and municipal record-keeping review. At minimum, the data structure should produce and retain:

  • An append-only audit log of every insert, interval closure, and retraction, keyed by source_hash, with the actor (pipeline run ID), timestamp, and the before/after version interval.
  • Provenance per row — the SHA-256 source_hash and the verbatim raw_payload, so any reported value can be traced back to the exact municipal response it came from.
  • A run manifest recording pinned library versions (PostGIS, GEOS, GeoPandas, Shapely), the working CRS, and the batch boundaries, so a historical write can be reproduced deterministically.
  • Reconstruction queries — saved as-of and as-recorded queries that regenerate the parcel’s state for any date pair on demand, which is the artifact an auditor or counterparty actually asks for.

Anchoring all spatial and tabular operations to immutable, bi-temporally versioned records is what lets an engineering team reconstruct any historical zoning state, validate feasibility against past code cycles, and produce audit-ready reports without ever trusting a mutable “current” value.

FAQ jump to heading

Why bi-temporal instead of a single effective-date timeline?

A single timeline answers "what was the zoning on date X" but not "what did our system believe on date X." Retroactive corrections and retractions make those two questions diverge, and underwriting, litigation support, and audit all need the second one. Bi-temporal modeling records valid time (real-world) and system time (when ingested) separately so both reconstructions stay exact.

Why store the raw municipal payload when you already have normalized columns?

Because normalization logic changes and feeds drift. Keeping the verbatim payload plus its SHA-256 hash lets you replay a corrected normalization over historical rows without re-fetching from the portal, prove exactly what was ingested, and trace any reported value back to its source response. It is the difference between an auditable system and one that asks people to trust it.

How do I handle a parcel that splits or merges?

The stable parcel_id is no longer 1:1 across the reapportionment boundary, so model lineage explicitly with a parent-to-child edge table carrying the effective date. Feasibility and history queries then follow that graph across the split or merge instead of losing the trail when the geometry changes identity.

What stops a re-run of the same feed from double-writing history?

Idempotency through the source_hash. Each incoming payload is canonicalized and hashed; if a row with that hash already exists the write is skipped. Combined with the interval-closure step running inside a single transaction, re-ingesting a feed is safe and never duplicates a state transition.

Should topology repair happen in Python or PostGIS?

Repair small or one-off layers in Python with shapely's make_valid; for enterprise batch volumes push it into PostGIS using ST_MakeValid during the upsert so you do not hold every geometry in memory. Either way, coerce the result back to the column's declared geometry type, because a repair can change a polygon into a geometry collection and the typed column will reject it.

Up: Municipal Zoning Data Architecture & Compliance Frameworks