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.
- Same ground, two ids, two codes. A spatial intersection returns a pair of polygons whose overlap is high but whose
parcel_idandzoning_codediffer, and whosesource_jurisdictiondiffers. This is the canonical annexation duplicate. - Partial geometry disagreement. The municipal survey re-drew the boundary, so the two polygons overlap by 90–99% rather than exactly. A naive
equalsorwithintest misses the match entirely and both records survive as distinct parcels. - Stale-but-not-wrong codes. The county
R-40is 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.
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_ioufrom 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 withbuffer(0). - Authority is deterministic. Feed the same pair in both orders and assert
rank_pairreturns the identical winner. Order sensitivity means the tiebreak is incomplete. - Lineage round-trips. Re-running an unchanged batch must reproduce identical
lineage_hashvalues, 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_tieproduced areviewrecord 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_geometryyields an empty or invalid winner even afterbuffer(0), write the pair to ageometry_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_hashso 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, andstatus, 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.
Related jump to heading
- Parent topic: Attribute Normalization Rules
- Section overview: Automated Feed Ingestion & GIS Data Parsing
- Zoning Taxonomy Mapping — resolving R-40 and RMF-2 to comparable standardized classes
- Data Lineage & Provenance Tracking — stamping and storing the reconciliation lineage hash
- Normalizing zoning attributes across different city schemas — the schema-normalization pass that precedes reconciliation