Resolving Overlapping Jurisdiction Boundaries in Setback Analysis
The scenario: a parcel on the edge of an incorporated town is screened against the county ordinance, clears a 300-metre dwelling setback, and is then rejected in pre-application review because the town requires 500 metres and the parcel sits inside its extraterritorial jurisdiction. The geometry was right, the ordinance lookup was right for the county, and nothing in the pipeline knew that two ordinances applied at once. This page makes the precedence explicit, and it extends regulatory boundary mapping.
Root-cause analysis
Overlapping jurisdiction is the normal case, not the exception, and three assumptions break on it.
- One parcel, one jurisdiction. A parcel can sit inside a county, an incorporated municipality’s extraterritorial jurisdiction, a special district and a state overlay simultaneously, and each may publish its own setback. A point-in-polygon join returning the first match picks one arbitrarily.
- Nearest-boundary attribution. Assigning a parcel to whichever jurisdiction polygon its centroid falls in fails for parcels that straddle a boundary — and a straddling parcel is subject to both ordinances over the parts they cover, not to one over all of it.
- Precedence hard-coded as an ordering. Writing “county then municipality” into the loop encodes a legal judgement in code, where nobody reviews it. Where a state pre-emption statute inverts the usual order, the code is quietly wrong for that state and nothing indicates it.
Pre-flight validation
Establish how much of the problem exists before designing for it: count the parcels touched by more than one jurisdiction, and the parcels that straddle a boundary rather than sitting inside one.
import geopandas as gpd
def jurisdiction_overlap_profile(
parcels: gpd.GeoDataFrame,
jurisdictions: gpd.GeoDataFrame,
*,
id_field: str = "parcel_id",
juris_field: str = "jurisdiction_id",
) -> dict:
"""How many parcels have more than one applicable ordinance, and how many straddle."""
joined = gpd.sjoin(parcels[[id_field, "geometry"]], jurisdictions[[juris_field, "geometry"]],
how="left", predicate="intersects")
per_parcel = joined.groupby(id_field)[juris_field].nunique()
straddling = []
multi = per_parcel[per_parcel > 1].index
for pid in multi:
geom = parcels.loc[parcels[id_field] == pid, "geometry"].iloc[0]
covers = jurisdictions[jurisdictions.intersects(geom)]
wholly_inside = any(geom.within(g) for g in covers.geometry)
if not wholly_inside:
straddling.append(pid)
return {
"parcels": len(parcels),
"single_jurisdiction": int((per_parcel == 1).sum()),
"multi_jurisdiction": int((per_parcel > 1).sum()),
"straddling_a_boundary": len(straddling),
"max_jurisdictions_on_one_parcel": int(per_parcel.max()) if len(per_parcel) else 0,
}
On a typical county-edge portfolio this returns something like eight percent multi-jurisdiction and two percent straddling — small enough to ignore by accident and large enough to lose a project.
Fix implementation
The resolution has two parts: a precedence table that lives in configuration with a citation, and a per-parcel evaluation that applies every applicable rule and records which one bound.
from dataclasses import dataclass
import geopandas as gpd
@dataclass(frozen=True)
class OrdinanceRule:
jurisdiction_id: str
level: str # "state" | "county" | "municipal" | "district"
setback_m: float
citation: str
pre_empts: tuple[str, ...] = () # levels this rule overrides where statute says so
def applicable_setback(
parcel_geom,
jurisdictions: gpd.GeoDataFrame,
rules: dict[str, OrdinanceRule],
*,
juris_field: str = "jurisdiction_id",
) -> dict:
"""Every rule that applies, the one that binds, and why."""
touching = jurisdictions[jurisdictions.intersects(parcel_geom)]
applicable = [rules[j] for j in touching[juris_field] if j in rules]
if not applicable:
raise ValueError("no ordinance found for this parcel — the jurisdiction layer has a hole")
# Pre-emption first: a rule that pre-empts a level removes that level from contention.
pre_empted_levels = {lvl for r in applicable for lvl in r.pre_empts}
contenders = [r for r in applicable if r.level not in pre_empted_levels] or applicable
# Default among survivors: the most restrictive applies. Both parts are policy,
# so both are stated here rather than implied by an ordering somewhere.
binding = max(contenders, key=lambda r: r.setback_m)
return {
"setback_m": binding.setback_m,
"binding_jurisdiction": binding.jurisdiction_id,
"binding_level": binding.level,
"citation": binding.citation,
"applicable": [
{"jurisdiction": r.jurisdiction_id, "level": r.level, "setback_m": r.setback_m}
for r in applicable
],
"pre_empted_levels": sorted(pre_empted_levels),
}
Returning the whole applicable list alongside the binding rule is what makes the result reviewable. A land team asking “why 500 metres and not 300” gets the answer from the record instead of from a rerun.
Handling a parcel that straddles a boundary
A parcel lying partly in two jurisdictions is not subject to the stricter rule everywhere — it is subject to each rule over the part that jurisdiction covers. Two treatments are defensible and they give different answers.
Split and evaluate per part. Intersect the parcel with each jurisdiction, apply that jurisdiction’s setback to its own piece, and union the buildable remainders. This is the legally accurate treatment and produces a buildable envelope with a discontinuity at the boundary, which is what actually exists.
Apply the most restrictive to the whole parcel. Simpler, conservative, and wrong in the direction that loses buildable land. It is a reasonable screening default and a poor basis for a layout, because it discards area the parcel genuinely has.
The choice belongs in configuration, and the output should say which was used. For a screening pass the conservative treatment is fine as long as the straddling parcels are flagged; for a shortlisted site, split and evaluate per part before any layout work begins.
Downstream validation
def assert_jurisdiction_attribution(results, *, require_citation: bool = True) -> None:
"""Every parcel must name a binding rule, and the binding rule must be applicable."""
for pid, res in results.items():
assert res["applicable"], f"{pid}: no applicable ordinance recorded"
binding = res["binding_jurisdiction"]
assert any(a["jurisdiction"] == binding for a in res["applicable"]), (
f"{pid}: binding jurisdiction {binding} is not in the applicable list"
)
assert res["setback_m"] == max(
a["setback_m"] for a in res["applicable"]
if a["level"] not in res["pre_empted_levels"]
), f"{pid}: binding setback is not the most restrictive among non-pre-empted rules"
if require_citation:
assert res.get("citation"), f"{pid}: binding rule has no citation — unreviewable"
Frequently asked questions
Is “most restrictive wins” always right?
It is the right default and not a universal rule. Several states pre-empt local wind or solar siting ordinances outright, and in those the state rule governs even when it is less restrictive. Encoding the default as policy with an explicit pre-emption list — rather than as an ordering in a loop — is what lets a per-state exception be added without touching the evaluation logic.
What about a parcel with no jurisdiction at all?
Treat it as an error in the boundary layer rather than as an absence of regulation. Unincorporated land still sits in a county, so a parcel matching nothing usually means a gap or a CRS problem in the jurisdiction layer. Failing loudly is right: a silently unregulated parcel is the most dangerous possible output of this stage.
How should extraterritorial jurisdiction be represented?
As its own polygon with its own rule, not as an extension of the municipal boundary. ETJ areas frequently carry a different setback from the municipality proper, and merging them into one polygon makes that distinction unrepresentable. The same applies to overlay districts and special-purpose districts.
Does the precedence table need a vintage?
Yes, per rule. Ordinances are amended on their own schedules, and a screening result is only as current as the rule it applied. Recording the adoption date and the citation with each rule lets a refresh list exactly which parcels are affected when one changes.
How do I keep the table maintainable across hundreds of jurisdictions?
Store it as data with a citation per row, keep it under version control, and treat an edit as a reviewable change rather than a configuration tweak. The volume is manageable because most jurisdictions default to their county’s rule — the table only needs the ones that differ, plus a documented default.
What should the screening output carry per parcel?
The binding setback, the jurisdiction and level that produced it, the citation, the full applicable list, and the straddling treatment used. Those five fields answer every question the next reviewer asks, and all five are already computed by the evaluation above.
Should the repair run on ingestion or before each analysis?
On ingestion, with a cheap re-assertion before an expensive analysis. Repairing at the boundary means the working store carries one invariant — every geometry is valid — and every consumer can rely on it. The re-assertion before a long overlay costs a second against an indexed frame and catches the case where something wrote to the store outside the pipeline, which is worth far more than it costs.
How do I tell a genuine multipart parcel from a repaired bowtie?
By the source, not by the geometry. Genuine multipart holdings are common — a farm either side of a
road is one parcel with two polygons — and they arrive multipart from the county. A parcel that was
single-part on input and multipart on output was changed by the repair, which is exactly what the
parts_after column in the report records. Comparing input and output part counts separates the two
without any judgement about shape.
Related
- Regulatory Boundary Mapping — the parent workflow and its precedence discussion
- Clipping Solar Parcels to County Setback Boundaries in GeoPandas — applying the resolved setback geometrically
- Automating US County Boundary Extraction with OSMnx — sourcing the jurisdiction polygons
- Calculating Buildable Area After Setback and Habitat Exclusions — the consumer of the resolved setback