Detecting overlay changes that leave the base code untouched
The city adopts a wellhead-protection overlay covering 900 parcels. Two permitted uses are removed from every one of them. Your change detector reports nothing, because every parcel is still zoned R-1 and always was. Nine months later a client asks why your platform never flagged the ordinance that killed their fuel-storage plan, and the answer is that the pipeline was watching the base code — which is exactly what an overlay does not change. This guide closes that hole: it puts overlay membership into the change identity, backfills the amendments already missed, and alerts on them like any other rezone. It is the change-detection half of overlay district modeling.
Diagnosis: measuring the hole before you fix it jump to heading
The hole is measurable directly from your own history, and the number is usually larger than expected.
def missed_overlay_amendments(store, since):
"""Compare overlay membership between consecutive versions of each parcel and
count the transitions the change feed never reported."""
missed = []
for parcel_id, versions in store.iter_version_pairs(since=since):
for prev, cur in versions:
if prev.base_code == cur.base_code and \
set(prev.overlay_codes) != set(cur.overlay_codes):
reported = store.change_event_exists(parcel_id, cur.valid_from)
if not reported:
missed.append({
"parcel_id": parcel_id,
"effective": cur.valid_from,
"added": sorted(set(cur.overlay_codes) - set(prev.overlay_codes)),
"removed": sorted(set(prev.overlay_codes) - set(cur.overlay_codes)),
})
return missed
Two things fall out of that report. The count itself, which tells you how much regulatory change the pipeline has been blind to; and the clustering — overlay amendments arrive as blocks of hundreds of parcels sharing one effective date, because they are ordinances rather than individual rezones. A report with 900 parcels on one date is one amendment, not nine hundred events, and that distinction matters for how it gets notified.
If the version history does not record overlays separately, this query cannot be written at all — which is itself the finding, and the reason composing base zoning and overlay districts has to come first.
Step-by-step implementation jump to heading
1. Put the overlay set in the change hash jump to heading
import hashlib
import json
def change_digest(version) -> str:
"""The canonical identity of a parcel's regulatory state.
Overlays are SORTED, because a portal that returns them in a different order on
two consecutive runs would otherwise produce a change event every run. They are
a set, not a sequence — order carries no meaning here.
"""
payload = {
"base": version.base_code,
"overlays": sorted(version.overlay_codes),
"effective": version.valid_from.isoformat(),
"geometry": version.canonical_geometry_digest,
}
return hashlib.sha256(
json.dumps(payload, separators=(",", ":"), sort_keys=True).encode()
).hexdigest()
2. Classify the change so it can be handled distinctly jump to heading
An overlay-only amendment is a real change class, not a footnote on rezone, because it needs different notification copy and a different audit trail.
OVERLAY_ONLY = "overlay_only"
def classify(prev, cur) -> str:
if prev is None:
return "new_parcel"
base_changed = prev.base_code != cur.base_code
overlays_changed = set(prev.overlay_codes) != set(cur.overlay_codes)
geom_changed = prev.canonical_geometry_digest != cur.canonical_geometry_digest
if geom_changed and not (base_changed or overlays_changed):
return "boundary_adjustment"
if base_changed and overlays_changed:
return "rezone_with_overlay_change"
if base_changed:
return "rezone"
if overlays_changed:
return OVERLAY_ONLY # the class that used to be invisible
return "no_change"
3. Describe the change in terms of what it does, not what it is jump to heading
An alert saying “overlay WP added” is useless to a subscriber who does not know your overlay vocabulary. The useful alert names the effect, which means resolving standards before and after.
def describe_overlay_change(prev, cur, resolver) -> str:
before = resolver.resolve(prev)
after = resolver.resolve(cur)
added = sorted(set(cur.overlay_codes) - set(prev.overlay_codes))
removed = sorted(set(prev.overlay_codes) - set(cur.overlay_codes))
lost_uses = sorted(before.uses - after.uses)
gained_uses = sorted(after.uses - before.uses)
numeric = {k: (before.numeric.get(k), v)
for k, v in after.numeric.items() if before.numeric.get(k) != v}
parts = []
if added:
parts.append("overlay added: " + ", ".join(added))
if removed:
parts.append("overlay removed: " + ", ".join(removed))
if lost_uses:
parts.append(f"{len(lost_uses)} use(s) no longer permitted: "
+ ", ".join(lost_uses[:3]) + ("…" if len(lost_uses) > 3 else ""))
if gained_uses:
parts.append(f"{len(gained_uses)} use(s) newly permitted")
for k, (was, now) in numeric.items():
parts.append(f"{k}: {was} → {now}")
return "; ".join(parts) or "overlay membership changed with no effect on standards"
The last fallback is worth keeping: some overlay changes are administrative and genuinely alter no standard. Reporting that honestly is better than implying a consequence that does not exist.
4. Group by ordinance, notify once jump to heading
def group_by_amendment(changes):
"""900 parcels sharing an effective date and an overlay delta is ONE ordinance.
Grouping before notification is what keeps the alert channel usable."""
from collections import defaultdict
groups = defaultdict(list)
for ch in changes:
if ch.change_class != OVERLAY_ONLY:
continue
key = (ch.effective_date, tuple(ch.overlays_added), tuple(ch.overlays_removed))
groups[key].append(ch.parcel_id)
return [{"effective": k[0], "added": list(k[1]), "removed": list(k[2]),
"parcels": v, "parcel_count": len(v)} for k, v in groups.items()]
5. Backfill the amendments you already missed jump to heading
Run the diagnosis query, group the results, and emit the change events with their true effective dates — the same ordered-replay discipline as backfilling a missed week of county feeds. Notify once per amendment, not once per parcel, and label the notifications as historical.
Verification & testing jump to heading
def test_overlay_only_change_is_detected():
prev = version(base="R-1", overlays=["HO"])
cur = version(base="R-1", overlays=["HO", "WP"])
assert classify(prev, cur) == OVERLAY_ONLY
assert change_digest(prev) != change_digest(cur)
def test_overlay_order_is_not_a_change():
a = version(base="R-1", overlays=["HO", "WP"])
b = version(base="R-1", overlays=["WP", "HO"])
assert classify(a, b) == "no_change"
assert change_digest(a) == change_digest(b) # sorted before hashing
def test_description_names_the_effect_not_the_token(resolver):
prev = version(base="R-1", overlays=[])
cur = version(base="R-1", overlays=["WP"])
text = describe_overlay_change(prev, cur, resolver)
assert "WP" in text
assert "no longer permitted" in text # the part a subscriber can act on
def test_one_ordinance_is_one_notification():
changes = [overlay_change(parcel=f"p{i}", effective="2026-06-01", added=["WP"])
for i in range(900)]
groups = group_by_amendment(changes)
assert len(groups) == 1 and groups[0]["parcel_count"] == 900
The order-independence test is the one that prevents a regression nobody would otherwise notice: without sorting, a portal reordering its overlay array produces 41 000 phantom change events overnight.
Failure recovery jump to heading
Months of overlay amendments never reported. Backfill from the version history as above. Where the history does not separate overlays, the amendments must be recovered from the source archive, and where that is absent, from the jurisdiction’s ordinance record — which is slower but authoritative.
Phantom overlay changes flooding the feed. Almost always unsorted overlay arrays. Normalise, add the sort constraint at the database level, and re-run detection over the affected window to retract the phantom events.
Subscribers who received 900 notifications for one ordinance. Send one grouped summary and enable grouping before doing anything else. Nine hundred alerts for one ordinance is how a channel gets muted, and a muted channel misses the next real change.
Frequently asked questions jump to heading
Why not just include the raw fused zoning string in the hash?
Because it makes the change feed depend on the portal’s formatting. A county that switches from R-1-HO to R-1/HO, or reorders the tokens, would emit a change for every parcel while nothing regulatory happened. Hashing the parsed base plus the sorted overlay set is stable against presentation changes and sensitive to real ones.
Should an overlay-only change alert at the same priority as a rezone?
Yes in substance, and the copy should differ. From a landowner’s point of view, having two permitted uses removed by an overlay is not meaningfully different from a rezone that removes them. What differs is the explanation, which is why the alert resolves standards before and after rather than naming an overlay token the subscriber has never heard of.
Do overlay changes need their own change class?
It pays for itself. The class drives grouping — overlay amendments arrive as hundreds of parcels sharing one effective date, unlike rezones — and it lets a consumer filter for them specifically, which is exactly what somebody auditing “did we ever report this ordinance?” wants to do.
What about an overlay change that alters no standard?
Report it as such. Administrative overlay changes exist — a renamed district, a re-adopted boundary with identical rules — and stating that standards are unaffected is more useful than either silence or an implied consequence. The resolver already computes both sides, so the comparison is free.
Related jump to heading
- Parent topic: Overlay District Modeling
- Section overview: Municipal Zoning Data Architecture & Compliance Frameworks
- Composing base zoning and overlay districts — without a separated overlay set there is nothing to hash
- Change Detection & Geometry Diffing — the canonical hash this extends
- Zoning Change Alerting — grouping and suppression for a 900-parcel ordinance