Reconciling conflicting zoning codes across annexation boundaries

The morning after Cary annexes a strip of unincorporated Wake County, your normalization job ingests two feeds and produces a parcel that exists twice. The county feed still carries PIN 0745-32-8891 as R-40 (residential, forty-thousand-square-foot minimum lot); the municipal feed now carries the same ground as RMF-2 (residential multi-family) under a fresh municipal parcel id, with a boundary that overlaps the county polygon by ninety-four percent but not cleanly. Downstream, an impact model sees two districts stacked on one lot and either double-counts the acreage or picks whichever record sorted last. This page answers one narrow question: after an annexation, when a parcel appears in both the city and county feeds with different zoning codes and overlapping geometry, how does normalization deterministically pick the authoritative record and prove why it chose it? It is a focused companion to the broader attribute normalization rules that harden the wider automated zoning change and municipal GIS tracking workflow. The fix is a candidate-matching pass, an explicit authority ranking, a geometry-overlap resolution step, and a reconciliation record you can audit and roll back.

Diagnosis: detecting duplicate parcels with conflicting codes jump to heading

Annexation does not delete anything. The county keeps publishing the parcel until its next cadastral cycle — often a quarter or more behind the annexation ordinance — while the municipality starts publishing the same ground immediately under its own scheme. So the duplicate is not a bug in one feed; it is the correct, simultaneous state of two authorities that have not yet reconciled with each other. Your pipeline has to do the reconciliation they have not.

The conflict shows up as three recognizable symptoms once both feeds land in the same staging table.

  1. Same ground, two ids, two codes. A spatial intersection returns a pair of polygons whose overlap is high but whose parcel_id and zoning_code differ, and whose source_jurisdiction differs. This is the canonical annexation duplicate.
  2. Partial geometry disagreement. The municipal survey re-drew the boundary, so the two polygons overlap by 90–99% rather than exactly. A naive equals or within test misses the match entirely and both records survive as distinct parcels.
  3. Stale-but-not-wrong codes. The county R-40 is not corrupt data — it was authoritative until the ordinance’s effective date. Discarding it silently destroys the lineage a compliance auditor needs to explain why the district changed.

Reproduce the collision deterministically before writing any resolution logic, so you can measure the fix:

import geopandas as gpd

county = gpd.read_file("wake_county_parcels.geojson").to_crs(2264)   # NC State Plane ft
city = gpd.read_file("cary_parcels.geojson").to_crs(2264)

# Spatial self-join: which county parcels are covered by a city parcel?
overlaps = gpd.sjoin(county, city, how="inner", predicate="intersects")
conflicts = overlaps[overlaps["zoning_code_left"] != overlaps["zoning_code_right"]]

print(f"overlapping pairs={len(overlaps)}  conflicting codes={len(conflicts)}")
print(conflicts[["parcel_id_left", "zoning_code_left",
                 "parcel_id_right", "zoning_code_right"]].head())

A non-empty conflicts frame on a boundary you know was just annexed confirms the duplicate. Both feeds are internally valid, so no single-feed validation rule will catch this — reconciliation has to compare across sources.

Annexation zoning-code reconciliation flow Two source feeds, a county parcel and a city parcel covering the same ground, enter a candidate matcher that pairs records by spatial overlap and id. Matched pairs pass to an authority ranker that scores each source by jurisdiction precedence and effective date. The winner and loser then enter a geometry resolver that clips overlap to the authoritative boundary. The result becomes a reconciliation record carrying both codes and a lineage hash, while pairs the ranker cannot decide are routed to a manual-review queue. pair winner + loser tie / no authority County feed R-40 · PIN City feed RMF-2 · new id candidate match overlap ≥ threshold authority rank jurisdiction · date geometry resolve clip to winner reconciliation record + hash manual review queue flag · block publish

Step-by-step implementation jump to heading

Each step isolates one concern; the final orchestrator threads them together.

Step 1 — Match candidate pairs by spatial overlap and id jump to heading

Do not trust parcel_id equality across jurisdictions — the city minted a new id. Match on geometry first, using intersection-over-union (IoU) so a re-surveyed boundary still pairs, then attach any shared PIN as a secondary signal. Reproject both feeds to a common equal-area or State Plane CRS in feet before measuring area.

import geopandas as gpd
from dataclasses import dataclass

@dataclass
class Candidate:
    county_idx: int
    city_idx: int
    iou: float

def match_candidates(county: gpd.GeoDataFrame, city: gpd.GeoDataFrame,
                     min_iou: float = 0.80) -> list[Candidate]:
    """Pair county/city parcels by intersection-over-union of geometry."""
    joined = gpd.sjoin(county, city, how="inner", predicate="intersects")
    pairs: list[Candidate] = []
    for c_idx, row in joined.iterrows():
        city_idx = row["index_right"]
        g1, g2 = county.geometry[c_idx], city.geometry[city_idx]
        inter = g1.intersection(g2).area
        union = g1.union(g2).area
        iou = inter / union if union else 0.0
        if iou >= min_iou:
            pairs.append(Candidate(c_idx, city_idx, round(iou, 4)))
    return pairs

Step 2 — Rank each record by jurisdiction authority and effective date jump to heading

The heart of reconciliation is a deterministic authority function. After annexation the municipality is the authoritative zoning source for that ground, but only from the ordinance’s effective date forward. Encode both rules: a jurisdiction precedence tier and an effective-date tiebreak. Never let record insertion order decide.

from datetime import date

# Higher tier wins for ground inside its incorporated limits.
JURISDICTION_TIER = {"municipal": 2, "county": 1, "state": 0}

def authority_score(record: dict, as_of: date) -> tuple:
    """Return a sortable key; larger sorts first (more authoritative)."""
    tier = JURISDICTION_TIER.get(record["source_jurisdiction"], -1)
    eff = record.get("effective_date")
    # A code whose effective_date is after the query date is not yet in force.
    in_force = 1 if (eff is None or eff <= as_of) else 0
    # Prefer in-force, then higher jurisdiction, then most recent effective date.
    return (in_force, tier, eff or date.min)

def rank_pair(county_rec: dict, city_rec: dict, as_of: date):
    ranked = sorted([county_rec, city_rec], key=lambda r: authority_score(r, as_of),
                    reverse=True)
    winner, loser = ranked
    # Tie: same tier AND same in-force state -> cannot auto-resolve.
    if authority_score(winner, as_of) == authority_score(loser, as_of):
        return None, None
    return winner, loser

Step 3 — Resolve the geometry overlap jump to heading

The winning code owns the overlap, but the loser polygon may extend beyond the annexed area (a county parcel split by the city limit). Clip the winner’s authority to the annexation boundary and subtract it from the loser so no ground is double-claimed. This keeps total acreage conserved.

from shapely.ops import unary_union

def resolve_geometry(winner_geom, loser_geom, boundary_geom):
    """Winner keeps the annexed overlap; loser keeps only the remainder."""
    annexed = winner_geom.intersection(boundary_geom)
    winner_final = winner_geom if annexed.is_empty else annexed
    # Loser retains any area outside the winner's authoritative footprint.
    loser_final = loser_geom.difference(winner_final)
    if not loser_final.is_valid:
        loser_final = loser_final.buffer(0)  # heal self-intersections
    return winner_final, loser_final

Step 4 — Emit a reconciliation record jump to heading

Produce one immutable record per resolved conflict that carries both codes, the decision, and a lineage hash tying it to the two source rows. This is the artifact an auditor reads to explain the district change; the superseded county code is retained, not deleted. Normalize both codes against the zoning taxonomy mapping before serialization so R-40 and RMF-2 resolve to comparable standardized classes.

import hashlib, json
from datetime import datetime, timezone
from pydantic import BaseModel

class ReconciliationRecord(BaseModel):
    parcel_key: str
    authoritative_code: str
    superseded_code: str
    winner_source: str
    iou: float
    decided_as_of: str
    lineage_hash: str
    status: str  # "resolved" | "review"

def build_record(winner: dict, loser: dict, iou: float, as_of) -> ReconciliationRecord:
    seed = f"{winner['parcel_id']}|{loser['parcel_id']}|{winner['zoning_code']}"
    lineage = hashlib.sha256(seed.encode()).hexdigest()
    return ReconciliationRecord(
        parcel_key=winner["parcel_id"],
        authoritative_code=winner["zoning_code"],
        superseded_code=loser["zoning_code"],
        winner_source=winner["source_jurisdiction"],
        iou=iou,
        decided_as_of=as_of.isoformat(),
        lineage_hash=lineage,
        status="resolved",
    )

Step 5 — Flag unresolved conflicts for review jump to heading

When rank_pair returns a tie — two records at the same tier with the same in-force state, common at a boundary annexed in phases — do not guess. Write a review record, block that parcel from publishing, and route it to a human queue. Silent auto-resolution of a genuine ambiguity is worse than a held row.

def reconcile(pairs, county, city, boundary, as_of):
    resolved, review = [], []
    for cand in pairs:
        c_rec = county.iloc[cand.county_idx].to_dict()
        m_rec = city.iloc[cand.city_idx].to_dict()
        winner, loser = rank_pair(c_rec, m_rec, as_of)
        if winner is None:
            review.append({"county": c_rec["parcel_id"], "city": m_rec["parcel_id"],
                           "iou": cand.iou, "reason": "authority_tie"})
            continue
        resolve_geometry(winner["geometry"], loser["geometry"], boundary)
        resolved.append(build_record(winner, loser, cand.iou, as_of))
    return resolved, review

Verification & testing jump to heading

Confirm reconciliation resolved the conflict rather than hiding it.

  • One authoritative code per ground. Re-run the spatial self-join on the reconciled output and assert zero conflicting-code overlaps above the IoU threshold. Any survivor is a pair that never entered the matcher — usually an IoU below min_iou from a heavy re-survey.
  • Acreage is conserved. Sum parcel areas before and after resolve_geometry; total ground must match within floating tolerance. A shortfall means the difference operation dropped a sliver — heal it with buffer(0).
  • Authority is deterministic. Feed the same pair in both orders and assert rank_pair returns the identical winner. Order sensitivity means the tiebreak is incomplete.
  • Lineage round-trips. Re-running an unchanged batch must reproduce identical lineage_hash values, proving both source ids and the winning code fed the hash. This is the record the data lineage and provenance tracking layer stamps and stores.
  • Review queue is non-silent. Assert every authority_tie produced a review record and that none of those parcels appear in the published table.

Failure recovery jump to heading

When reconciliation stalls mid-batch, the run must continue and stay reproducible.

  • Hold, never overwrite. A parcel that cannot be auto-decided keeps its previous published state until a human resolves the review record. Never let an ambiguous annexation blank out a live zoning code.
  • Quarantine invalid geometry. If resolve_geometry yields an empty or invalid winner even after buffer(0), write the pair to a geometry_review/ partition with both source hashes and the IoU, then continue. A collapsed polygon usually signals mismatched CRS units, not a code conflict.
  • Pin the effective date per ordinance. Store the annexation ordinance’s effective date in a lookup keyed on the boundary id, not inferred from feed timestamps. A feed’s ingest time is not the legal effective date, and using it silently mis-ranks records near the transition.
  • Make reruns idempotent. Key the target table on lineage_hash so replaying a batch upserts rather than duplicates. Reconciliation is often re-run when the lagging county feed finally catches up.
  • Alert on review-rate drift. Emit structured logs with boundary_id, iou, winner_source, and status, and alert when the review rate exceeds a few percent of a boundary’s parcels — a spike usually means a whole annexation phase lacks an effective date in the lookup.

Frequently asked questions jump to heading

Why match on geometry instead of the parcel id?

Because the annexing municipality mints its own parcel id, the county PIN and the city id refer to the same ground under different keys. Matching on intersection-over-union pairs the records even when the survey re-drew the boundary; a shared PIN, when present, is only a secondary confirmation signal.

Should I delete the superseded R-40 county code?

No. It was the authoritative code until the ordinance’s effective date, and an auditor needs it to explain why the district changed. Keep it in the reconciliation record’s superseded_code field so the transition is fully traceable rather than silently rewritten.

What decides which jurisdiction wins?

A deterministic authority function: a jurisdiction precedence tier (municipal outranks county for incorporated ground) combined with an effective-date check so a not-yet-in-force code cannot win. When both records tie on tier and in-force state, the pair is flagged for human review rather than auto-resolved.

The two polygons overlap by 92% — is that a match?

Yes, if it clears your IoU threshold. Annexation re-surveys routinely shift a boundary by a few feet, so an exact-equality test would miss the duplicate. Set min_iou around 0.80 and route lower-overlap pairs to review rather than dropping or force-merging them.