Composing base zoning and overlay districts without losing either
The portal publishes one field, zoning, and its values look like R-1, R-1-HO, RSF1/WP, R1(A), and MU-2-TOD-HP. Your crosswalk is keyed on base codes, so R-1-HO either fails to resolve or — worse — matches a rule that strips unknown suffixes and resolves to whatever R-1 resolves to. Either way the historic overlay is gone, and the parcel’s facade requirements went with it. The overlay was never lost by a bug; it was discarded by a parser that had no concept of it. This guide splits fused codes safely, as the ingestion-side companion to overlay district modeling.
Diagnosis: finding the overlays hiding in your base codes jump to heading
Start from the distinct values, not from the records. A county with 41 000 parcels usually has fewer than 200 distinct zoning strings, which is small enough to inspect.
def audit_codes(parcels, crosswalk):
"""Every distinct code, with whether the crosswalk knows it and whether it looks
like it has something appended."""
import re
rows = []
for code, n in parcels["zoning"].value_counts().items():
known = code in crosswalk
# A base code is usually a letter class plus a digit. Anything after that is
# a candidate overlay token — but "candidate" is the operative word.
m = re.match(r"^([A-Z]{1,3}-?\d{0,2}[A-Z]?)([-/(].+)?$", code.strip().upper())
rows.append({"code": code, "parcels": n, "in_crosswalk": known,
"base_guess": m.group(1) if m else None,
"tail": (m.group(2) or "").strip("-/()") if m else None})
return sorted(rows, key=lambda r: (-r["parcels"]))
Three patterns show up in the output, and only the first is safely splittable by a general rule.
A known base plus a known overlay token — R-1-HO where both R-1 and HO appear elsewhere independently. This is a fused pair and splitting it is correct.
A distinct base district that merely looks fused — R-1A is very often its own district with its own standards, not R-1 plus an A overlay. Splitting it invents an overlay and assigns the wrong base.
A base plus a token nobody recognises — could be either. This is the queue, not a decision.
The distinguishing test is the ordinance’s district list, and there is no substitute for it:
def classify_tail(base, tail, district_list, overlay_registry) -> str:
if f"{base}-{tail}" in district_list or f"{base}{tail}" in district_list:
return "distinct_district" # R-1A is its own district: do NOT split
if tail in overlay_registry:
return "known_overlay" # safe to split
return "unknown" # to review, never guessed
Step-by-step implementation jump to heading
1. Write the split as per-jurisdiction data, not as a regex jump to heading
Separator conventions differ by county — hyphen, slash, parentheses, sometimes a space — and so do the token vocabularies. A single regex tuned to one county silently mis-splits the next.
from dataclasses import dataclass, field
@dataclass(frozen=True)
class SplitRule:
jurisdiction: str
separators: tuple[str, ...] = ("-", "/", "(", ")")
# Tokens that are overlays IN THIS JURISDICTION. "HO" is a historic overlay in
# one city and a homeowner-association overlay in the next, so this is scoped.
overlay_tokens: frozenset[str] = frozenset()
# Codes that must never be split, however fused they look.
atomic_codes: frozenset[str] = frozenset()
max_overlays: int = 4
@dataclass(frozen=True)
class Split:
base: str
overlays: tuple[str, ...]
unresolved: tuple[str, ...] = ()
rule: str = ""
def split_code(raw: str, rule: SplitRule, district_list) -> Split:
code = " ".join(raw.upper().split())
if code in rule.atomic_codes or code in district_list:
# The whole string is a district in its own right. This check comes FIRST,
# which is what stops R-1A being torn into R-1 plus a phantom overlay.
return Split(code, (), rule=f"{rule.jurisdiction}:atomic")
tokens = [code]
for sep in rule.separators:
tokens = [t for part in tokens for t in part.split(sep) if t]
if not tokens:
return Split(code, (), rule="empty")
base, tail = tokens[0], tokens[1:]
if base not in district_list:
# The leading token is not a district either. Do not guess a base.
return Split(code, (), unresolved=tuple(tokens),
rule=f"{rule.jurisdiction}:base_unknown")
overlays = tuple(t for t in tail if t in rule.overlay_tokens)
unresolved = tuple(t for t in tail if t not in rule.overlay_tokens)
if len(overlays) > rule.max_overlays:
raise ValueError(f"{code}: {len(overlays)} overlays exceeds the declared "
f"maximum for {rule.jurisdiction}; the split rule is wrong")
return Split(base, overlays, unresolved, rule=f"{rule.jurisdiction}:split")
2. Prove the split round-trips jump to heading
A split you cannot reverse is a split you cannot trust. Recomposing the parts and comparing against the original catches separator and ordering mistakes immediately.
def recompose(split: Split, rule: SplitRule) -> str:
sep = rule.separators[0]
return sep.join((split.base, *split.overlays, *split.unresolved))
def round_trips(raw: str, rule: SplitRule, district_list) -> bool:
s = split_code(raw, rule, district_list)
return recompose(s, rule).replace(" ", "") == " ".join(raw.upper().split()).replace(" ", "")
Round-tripping will fail legitimately where the source uses mixed separators in one string — MU-2/TOD — so treat a failure as a signal to add the separator to the rule rather than as a data error.
3. Route the unresolved tokens, do not drop them jump to heading
def ingest_zoning(raw, rule, district_list, quarantine):
s = split_code(raw, rule, district_list)
if s.unresolved:
# An unrecognised token might be an overlay nobody has registered. Recording
# the parcel with a partial split and a flag is right; silently discarding
# the token is how a wellhead overlay disappears.
quarantine.record(raw, reason="unresolved_token", tokens=s.unresolved,
partial_base=s.base, partial_overlays=s.overlays)
return s
4. Store both, never the fused string alone jump to heading
ALTER TABLE parcel_version
ADD COLUMN base_code text NOT NULL,
ADD COLUMN overlay_codes text[] NOT NULL DEFAULT '{}',
ADD COLUMN raw_zoning text NOT NULL, -- exactly what the portal said
ADD COLUMN split_rule text NOT NULL; -- which rule produced the split
-- Overlay codes are sorted on write so the array is comparable and hashable; an
-- unsorted array makes every re-publication look like a change.
ALTER TABLE parcel_version
ADD CONSTRAINT overlay_codes_sorted
CHECK (overlay_codes = (SELECT array_agg(x ORDER BY x)
FROM unnest(overlay_codes) AS x));
Keeping raw_zoning is what makes the split auditable and revisable: when a token turns out to be an overlay rather than part of the base, the fix is a rule change plus a reprocess, not an archaeology exercise.
Verification & testing jump to heading
LARIMER = SplitRule("larimer", overlay_tokens=frozenset({"HO", "WP", "AH", "TOD"}),
atomic_codes=frozenset({"R-1A", "R-2B"}))
DISTRICTS = {"R-1", "R-2", "MU-2", "R-1A", "R-2B"}
@pytest.mark.parametrize("raw,base,overlays", [
("R-1", "R-1", ()),
("R-1-HO", "R-1", ("HO",)),
("MU-2-TOD-HP", "MU-2", ("TOD",)), # HP unregistered → unresolved
("R-1/WP", "R-1", ("WP",)),
])
def test_splits(raw, base, overlays):
s = split_code(raw, LARIMER, DISTRICTS)
assert s.base == base and s.overlays == overlays
def test_atomic_district_is_never_split():
s = split_code("R-1A", LARIMER, DISTRICTS)
assert s.base == "R-1A" and s.overlays == () # not R-1 + phantom "A"
def test_unresolved_token_is_kept_not_dropped():
s = split_code("MU-2-TOD-HP", LARIMER, DISTRICTS)
assert "HP" in s.unresolved # surfaced for registration
def test_round_trip():
for raw in ("R-1", "R-1-HO", "R-1A"):
assert round_trips(raw, LARIMER, DISTRICTS)
def test_every_distinct_code_is_covered(parcels):
"""The gate that matters in production: no distinct code may be unhandled."""
bad = [c for c in parcels["zoning"].unique()
if split_code(c, LARIMER, DISTRICTS).unresolved]
assert not bad, f"{len(bad)} distinct codes have unresolved tokens: {bad[:5]}"
That last test is the one to run against real data as a gate. It fails loudly when a county introduces a new overlay token, which is exactly the event you want to hear about.
Failure recovery jump to heading
A distinct district was split into a phantom overlay. The parcels now have the wrong base and an overlay that does not exist, and their resolved standards are wrong in both directions. Add the code to atomic_codes, reprocess from raw_zoning, and re-derive anything computed from the standards.
raw_zoning column: every one of these repairs is a rule change plus a reprocess if the original string was kept, and a re-fetch or an archaeology exercise if it was not.An overlay token was silently dropped by an earlier parser. Reprocess from raw_zoning — which is why that column exists. If the raw string was not kept, the overlays must be recovered from the source archive, and if that is also absent, from the portal by re-fetch.
Overlay arrays stored unsorted. Every re-publication compares unequal, so the change feed is full of no-op overlay changes. Normalise the arrays in place, add the sort constraint, and re-run the change detector for the affected window to remove the phantom events.
Frequently asked questions jump to heading
Why not just strip anything after the first hyphen?
Because R-1 itself contains a hyphen, and R-1A is frequently a distinct district rather than R-1 plus an overlay. A positional rule cannot distinguish a base code’s internal punctuation from a separator, so it either splits too eagerly and invents overlays or too timidly and loses them. The district list is the only authority that settles it.
Should overlay tokens be a global list?
No, for the same reason base codes are not. HO is a historic overlay in one jurisdiction and a homeowner-association overlay in the next, and A may be an overlay in one place and part of a district name in another. The token vocabulary is scoped to the jurisdiction, exactly as the crosswalk in zoning taxonomy mapping is.
What should happen to an unrecognised token?
It should be recorded on the parcel as unresolved and surfaced for registration — never dropped and never guessed at. Dropping it silently removes a real regulatory constraint; guessing assigns standards the ordinance does not support. A partial split with a flag is honest and is usually resolved by ten minutes with the ordinance’s district list.
Why keep the raw string when the split is stored?
Because the split embodies rules that will change. When a token is reclassified from unknown to overlay, or a code turns out to be atomic, reprocessing from the raw string is a re-run; reprocessing without it is a re-fetch at best and archaeology at worst. It costs one text column per version.
Related jump to heading
- Parent topic: Overlay District Modeling
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Zoning Taxonomy Mapping — the jurisdiction-scoped crosswalk the split feeds
- Attribute Normalization Rules — where a fused string is first parsed on ingest
- Schema Validation & Data Quality Checks — the gate that fails when a new token appears