Matching Features Across Vintages with Stable IDs Jump to heading
Deduplication within a delivery asks whether two records describe the same thing right now. Matching across vintages asks something harder: whether the parcel in this year’s delivery is the parcel from last year’s, after a boundary correction, a split, a merge, or a renumbering by a source system that considers its identifiers internal. Get it wrong in one direction and every feature looks new, so every downstream join breaks and the change report says the municipality was rebuilt. Get it wrong in the other and a genuine subdivision is recorded as a boundary edit, quietly erasing the event that mattered. This procedure implements the cross-delivery half of duplicate detection and feature deduplication, inside Spatial Data Quality Validation & Geometry Integrity.
The steps map to configure (Step 1, identifier-first matching), execute (Step 2, geometric fallback), validate (Steps 3–4, classification and identifier policy), and log (Step 5, the crosswalk). The organizing principle is that identity is a decision the publisher makes and records, never something inferred fresh on each run.
Prerequisites checklist Jump to heading
Step 1: Match on the persistent identifier before touching geometry Jump to heading
# geopandas >= 0.14 — Python 3.10+
import geopandas as gpd
CRS = "EPSG:25832"
previous = gpd.read_file("vintages/2025/parcels.gpkg").to_crs(CRS).set_index("stable_id")
current = gpd.read_file("vintages/2026/parcels.gpkg").to_crs(CRS).set_index("stable_id")
carried = previous.index.intersection(current.index)
dropped = previous.index.difference(current.index) # candidates for retired / split / merged
appeared = current.index.difference(previous.index) # candidates for created / split / merged
Doing this first is not just an optimization, though on a large layer it removes 95% of the work. It is a statement about authority: where a stable identifier exists on both sides, it decides the match, and no geometric evidence overrides it. A parcel whose boundary was corrected by four metres is still the same parcel if the identifier says so, and a pipeline that lets geometry outvote the identifier will re-invent identities every time a survey is refreshed.
Step 2: Compute overlap ratios for everything left over Jump to heading
# shapely >= 2.0 — Python 3.10+
from shapely import STRtree
def overlap_ratios(old_geoms, new_geoms):
"""Yield (old_index, new_index, ratio_of_old, ratio_of_new) for intersecting pairs."""
tree = STRtree(new_geoms)
for i, old in enumerate(old_geoms):
for j in tree.query(old, predicate="intersects").tolist():
shared = old.intersection(new_geoms[j]).area
if shared <= 0.0:
continue
yield i, j, shared / old.area, shared / new_geoms[j].area
Two ratios, not one. The fraction of the old feature covered by the new one and the fraction of the new feature covered by the old one answer different questions, and their combination is what distinguishes the lifecycle events. A split produces one old feature with several new ones, each covering a small fraction of the old but nearly all of themselves; a merge is the mirror image. Reporting a single Jaccard-style similarity collapses both into “somewhat similar” and loses the event.
Step 3: Classify the lifecycle event from the ratio pair Jump to heading
# Python 3.10+
RETAINED = 0.98 # both ratios at or above → geometry unchanged
CHANGED = 0.80 # both ratios at or above → same feature, edited boundary
PART_OF = 0.90 # one side almost fully covered → a split or merge component
def classify(old_to_new: float, new_to_old: float, old_partners: int, new_partners: int) -> str:
if old_to_new >= RETAINED and new_to_old >= RETAINED:
return "retained"
if old_to_new >= CHANGED and new_to_old >= CHANGED and old_partners == 1 == new_partners:
return "changed"
if new_to_old >= PART_OF and new_partners == 1 and old_partners > 1:
return "split" # this new feature is one piece of one old feature
if old_to_new >= PART_OF and old_partners == 1 and new_partners > 1:
return "merged" # this old feature was absorbed into one new feature
return "ambiguous"
ambiguous is a real outcome and must stay in the vocabulary. A parcel that was simultaneously split and had its boundary corrected produces ratios that fit no clean pattern, and forcing it into “changed” writes a false history. Ambiguous relationships go to review with both geometries attached, and their count over successive deliveries is a direct measure of how disruptive the source’s editing practices are.
Step 4: Assign identifiers by the lifecycle rule, and never reuse one Jump to heading
| Event | Identifier outcome | Rationale |
|---|---|---|
retained |
Keep the existing stable identifier | Nothing happened worth recording beyond the vintage |
changed |
Keep the identifier; record the boundary edit and its area delta | The feature persists; its shape was corrected |
split |
Retire the parent; mint one new identifier per child, each citing the parent | Two things now exist where one did; neither is “the” original |
merged |
Retire every parent; mint one new identifier citing all parents | The new feature is not either parent |
created |
Mint a new identifier with no predecessor | Genuinely new ground, e.g. reclaimed land or a first registration |
retired |
Retire the identifier with no successor | Deregistered; the identifier is never reissued |
ambiguous |
Hold both vintages; assign nothing until reviewed | Guessing here writes a false history that is hard to unwind |
The one rule with no exceptions is that a retired identifier is never reused. Reissuing 14-023-9 to a different piece of ground five years later silently corrupts every archived document, report and correspondence that cited it, and the corruption is undetectable from the data alone.
Whether split should keep the parent identifier for the largest child is the argument this table settles deliberately. Keeping it is tempting — most downstream joins survive — but it asserts that the 60% child is the parent, which is untrue and produces area histories that show a parcel shrinking by 40% with no event. Minting two children and citing the parent keeps the history honest, and the crosswalk in Step 5 is what keeps the joins working.
Step 5: Publish the crosswalk Jump to heading
# pyarrow >= 14 — Python 3.10+
import pyarrow as pa
CROSSWALK_SCHEMA = pa.schema([
("from_vintage", pa.string()), # "2025"
("to_vintage", pa.string()), # "2026"
("event", pa.string()), # retained | changed | split | merged | created | retired
("predecessor_id", pa.string()), # "" for created
("successor_id", pa.string()), # "" for retired
("old_to_new", pa.float64()),
("new_to_old", pa.float64()),
("area_delta", pa.float64()), # signed, in CRS units
("decided_by", pa.string()), # stable_id | overlap | manual_review
])
decided_by is the field that makes the table auditable rather than merely useful: it distinguishes relationships the identifier settled from those geometry inferred and from those a person adjudicated. When a downstream system disputes a match, that column is the first thing anyone looks at. The crosswalk joins the lineage manifest by vintage and belongs in the published dataset metadata as a distribution of its own — consumers need it more than they need most of what portals publish.
Verification Jump to heading
# pytest >= 7
def test_boundary_correction_keeps_the_identifier(vintage_pair):
crosswalk = match_vintages(*vintage_pair)
row = crosswalk.loc["PARCEL-14-023-9"]
assert row.event == "changed" and row.successor_id == "PARCEL-14-023-9"
def test_subdivision_retires_parent_and_mints_two_children(vintage_pair):
rows = crosswalk[crosswalk.predecessor_id == "PARCEL-14-050-0"]
assert set(rows.event) == {"split"} and len(rows) == 2
assert "PARCEL-14-050-0" not in set(crosswalk.successor_id)
def test_no_identifier_is_ever_reused(all_crosswalks):
retired = {r.predecessor_id for r in all_crosswalks if r.event in {"split", "merged", "retired"}}
minted = {r.successor_id for r in all_crosswalks if r.event in {"split", "merged", "created"}}
assert retired.isdisjoint(minted)
The third test runs over the whole crosswalk history rather than one pair of vintages, and it is the assertion most worth keeping: identifier reuse is introduced by a well-meaning change years after the code was written, and nothing else detects it.
A healthy annual delivery logs a distribution that should be boring:
INFO quality.vintage retained=212,004 changed=1,918 split=214 merged=97
INFO quality.vintage created=381 retired=142 ambiguous=6 decided_by_stable_id=213,922
Troubleshooting Jump to heading
| Symptom | Likely cause | Fix |
|---|---|---|
| Every feature classified as created and retired | The source reissued its internal identifiers and they were used as the stable identifier | Mint publisher-owned identifiers on first sight and store the source identifier as an ordinary attribute |
| Splits reported as changed | PART_OF threshold too low, so one child looks like the whole parent |
Raise the threshold and require old_partners > 1 explicitly, as in the classifier above |
| Large ambiguous population | Two vintages in different datums, so everything is offset | Check the transformation history before the match — see datum transformation fallback chains |
| Overlap computation is slow | Ratios computed for every pair rather than index candidates | Query the index per old feature, as in Step 2; never cross-join the vintages |
| Downstream joins break after a subdivision | The crosswalk is computed but not published | Publish it as a distribution alongside the dataset, not as an internal table |
| A retired identifier reappears | The mint function derives identifiers from source attributes that were recycled | Mint from a counter or a UUID and record the derivation; never derive identity from mutable attributes |
Related Jump to heading
- Duplicate Detection & Feature Deduplication — the parent stage and the within-delivery match rules
- Deduplicating Address Points with Spatial Clustering — the same discipline applied inside one delivery
- Best Practices for Spatial Data Dictionary Versioning — versioning the schema the identifiers live in
- Lineage Manifest Generation — where vintage-to-vintage events are retained as provenance