Geometry Validation & Topology Repair
Invalid geometry does not announce itself. A self-intersecting parcel ring will happily return an area, a buffer and an intersection, and every one of those numbers will be wrong in a way no exception reports. This module sits inside Core Geospatial Compliance Architecture & Regulatory Mapping and defines where repair happens, what it is allowed to change, and how the change is recorded — because a compliance pipeline that silently alters a parcel has altered the evidence its verdict rests on.
Prerequisites
- Parcel and overlay layers already in the authoritative projected CRS, as established by CRS standardization and datum management. Repairing in a geographic frame produces tolerances measured in degrees.
shapely2.0+ with a GEOS build that exposesmake_validandset_precision, andgeopandas1.0+ for vectorised application.- A stated area-delta budget per parcel — the fraction of area a repair may change before the parcel is routed to review rather than accepted.
- A repair log destination that is append-only, so the record of what changed survives the run that changed it.
- Agreement on which defects are repairable at all. A parcel with no geometry is not a repair case; it is a missing input.
What “Invalid” Actually Means
The OGC simple-features model defines validity precisely, and the definitions matter because each violation breaks a different downstream operation.
A self-intersecting ring — the classic bow tie — has an interior that is not well defined, so area is computed as the signed sum of two lobes and can come out smaller than either. A ring with the wrong orientation, or an interior ring outside its exterior, produces holes that behave as solid and vice versa. Duplicate consecutive vertices are harmless to area and fatal to some buffering implementations. And unclosed rings are usually rejected at read time, but a few formats tolerate them and close them differently.
from shapely.validation import explain_validity
def classify_invalidity(gdf):
"""Group invalid geometries by GEOS's own reason string.
The reason matters: a self-intersection and a hole-outside-shell need
different handling, and reporting them together hides that.
"""
bad = gdf[~gdf.geometry.is_valid]
return (bad.geometry.map(explain_validity)
.str.split("[").str[0].str.strip()
.value_counts()
.to_dict())
Reporting the reason distribution on every ingest is worth the two lines. A layer whose invalidity is dominated by one reason usually has a single upstream cause — a CAD export setting, a digitising convention — that is fixable at source, which is a far better outcome than repairing the same defect every week.
Where Repair Belongs in the Pipeline
The architectural decision is not how to repair but where, and there is only one answer that preserves reproducibility.
Repair belongs in ingestion, immediately after the frame is established and before anything measures. Repairing inside the evaluation stage is convenient and destroys determinism: the same parcel can be repaired differently on two runs depending on which rule touched it first, or repaired twice, or repaired by one engine and not another. Repairing at ingest means every consumer sees the same geometry, and the repaired layer is what gets snapshotted and hashed.
The repaired geometry should be written back to the working store rather than held in memory, and the original preserved. That gives the two things an audit needs: what the supplier published, and what the pipeline measured, with the difference between them recorded.
from shapely import make_valid, set_precision
def repair_layer(gdf, grid=0.001, budget=0.005):
"""Repair once, at ingest, with the change measured and bounded.
grid — precision snapping, in the working CRS unit, applied before repair
so that near-duplicate vertices collapse deterministically.
budget — fractional area change above which a parcel goes to review
instead of being accepted silently.
"""
out = gdf.copy()
before = out.geometry.area
snapped = out.geometry.map(lambda g: set_precision(g, grid))
fixed = snapped.map(lambda g: g if g.is_valid else make_valid(g))
after = fixed.area
delta = (after - before).abs() / before.replace(0, float("nan"))
out["geometry"] = fixed
out["repair_area_delta"] = delta.fillna(0.0)
out["repair_status"] = delta.gt(budget).map({True: "review", False: "accepted"})
return out
Note that precision snapping happens before validation rather than after. Snapping first collapses the near-duplicate vertices that cause most self-intersections, so make_valid has less to invent; snapping afterwards can reintroduce the defect that was just repaired.
The Area-Delta Budget
Every repair changes the parcel. The question that decides whether a repair is safe is not whether the geometry became valid but whether the change was small enough to leave the answers alone, and that is a threshold you can state.
A repair that changes a parcel’s area by a few thousandths of a percent is cleaning up digitising noise and cannot move a density or coverage verdict. A repair that changes it by several percent has almost certainly changed the answer to at least one rule, and accepting it silently means a verdict was computed from a parcel the supplier never published.
Setting the budget is a judgement, and it should be recorded where a reviewer can see it. A useful starting point is to derive it from the smallest margin any rule cares about: if the tightest coverage rule is decided at the tenth of a percent, a budget an order of magnitude below that leaves the verdicts untouched. Parcels exceeding it are not failures — they are the small population where the data is bad enough that somebody should look.
The budget also gives repair a natural test. Run the pipeline twice, once with repairs accepted and once with over-budget parcels excluded, and compare verdicts. A difference means the budget is too generous, which is a specific, fixable finding rather than a vague worry.
Slivers, Gaps and the Neighbour Problem
Validity is a property of one geometry. The defects that cause the most trouble in compliance work are properties of pairs of geometries, and they are invisible to is_valid.
Two agencies digitising the same municipal boundary independently produce a thin overlap along its whole length, or a thin gap, or alternating both. Neither polygon is invalid. Every parcel along that line, however, now has an ambiguous district assignment, an area that depends on which layer you believe, and a shared-boundary setback that measures to the wrong line.
Detection is straightforward once you look for it: intersect adjacent parcels and report any intersection whose area is non-zero but below a sliver threshold; union them and report holes below the same threshold. Both queries run on an indexed layer in seconds and produce a map of exactly where the two sources disagree.
Repair is a policy decision rather than a geometric one. Snapping one layer to the other privileges a source and should be a stated choice; averaging the two produces a boundary neither agency recognises. The workable default is to snap to the authoritative source — usually the assessor’s parcel fabric for parcels and the adopting agency’s layer for districts — with a tolerance derived from the fabric’s survey accuracy, and to flag anything the tolerance cannot close. The mechanics are worked through in snapping slivers between adjacent parcels.
Precision, and Why Snapping Comes First
Most invalid geometry in a parcel fabric is not dramatically broken. It is a boundary digitised twice at coordinates that differ in the eighth decimal place, or a vertex duplicated by an editing tool, or a ring that closes a nanometre away from where it started. GEOS is exact, and exactness applied to coordinates carrying more precision than the survey behind them produces defects that are arithmetic rather than real.
Precision snapping addresses this at the source. Rounding every coordinate to a grid — a millimetre, or a hundredth of a foot, well below any survey accuracy — collapses those near-duplicates into actual duplicates, which the repair operation then removes cleanly. It also makes the repair deterministic: two runs snap to the same grid and therefore produce the same geometry, whereas repairing unsnapped coordinates can resolve differently after an unrelated reprojection has perturbed the last decimal.
The grid size is a real decision and belongs in configuration. Too coarse and it moves boundaries by amounts a rule might notice; too fine and it collapses nothing. A useful anchor is two orders of magnitude below the fabric’s survey accuracy: a fabric accurate to a tenth of a foot is comfortably snapped at a thousandth, which is far below anything measurable and far above floating-point noise.
Snapping also has a pleasant side effect on adjacency. Two agencies’ boundaries that differ by sub-millimetre amounts become identical after snapping, which removes a whole population of hairline slivers before the sliver detection even runs. It will not close a real disagreement — nothing about snapping moves a boundary by a metre — but it clears the noise so the real disagreements are visible.
One caution: snap before validating, and snap once. Snapping after repair can reintroduce a defect that make_valid just resolved, and snapping repeatedly at different grids accumulates drift. Establishing the grid alongside the working CRS, as part of the frame configuration, keeps both decisions in one place where a reviewer can see them.
Repairing Layers, Not Just Geometries
A compliance run touches several layers, and their repair requirements differ enough that a single pass applied uniformly is usually wrong.
Parcel fabric is the most sensitive: its areas feed density and floor-area ratios, so an area-changing repair matters and the delta budget should be tight. District and overlay layers are less sensitive to area and more sensitive to boundaries, since what they contribute is a containment decision — a repair that moves a district edge by a metre changes which parcels are in it, while a repair that changes its total area by a percent may change nothing at all.
Constraint layers — wetlands, floodplains, easements — sit somewhere between, and they carry an additional consideration: they are frequently the least accurate data in the stack, so aggressive repair on them is repairing noise. The honest treatment is a light touch plus a recorded positional accuracy that travels with the layer, so downstream measurements can be qualified rather than presented at a precision the source never had.
Setting the budget per layer rather than globally takes one extra configuration field and prevents both failure modes: a budget tight enough for parcels being applied to a constraint layer that legitimately needs more work, and a budget loose enough for constraints being applied to the parcel fabric where it would let a real area change through.
Recording What Changed
A repair that is not recorded is indistinguishable from data the supplier published, which is exactly the confusion an audit trail exists to prevent.
The record needs five fields per repaired feature: the parcel identifier, the invalidity reason as GEOS reported it, the operation applied, the vertex count before and after, and the area delta. Together they let a reviewer answer the question that eventually gets asked — “did you change the parcel, and by how much?” — from the record alone.
def repair_record(parcel_id, reason, op, before_geom, after_geom):
"""The five facts that make a repair defensible."""
return {
"parcel_id": parcel_id,
"reason": reason, # from explain_validity
"operation": op, # "make_valid" | "snap" | "set_precision"
"vertices_before": len(before_geom.exterior.coords),
"vertices_after": len(after_geom.exterior.coords),
"area_delta_frac": abs(after_geom.area - before_geom.area) / before_geom.area,
}
Aggregate the same records into a per-run summary — how many parcels were repaired, by reason, and the total area moved — and publish it alongside the run. A week where the repair count jumps is a week where something changed upstream, and the summary is what makes that visible before it reaches a report.
Repairing at Scale
County fabrics contain hundreds of thousands of parcels and a small percentage of them are invalid, which makes repair a vectorised operation with a slow tail rather than a uniform cost.
The tail is where the time goes. A parcel with ten thousand vertices and a self-intersection takes orders of magnitude longer to repair than a simple quadrilateral, and a handful of those will dominate a run that is otherwise measured in seconds. Two measures keep it bounded: check validity first and repair only what fails, which is a cheap vectorised predicate over the whole layer; and set a per-geometry time budget, treating an exceedance as a quarantine rather than something to wait out.
Parallelism helps and needs the usual care. Repair is a pure function of one geometry, so it parallelises trivially across a partitioned layer with no shared state — provided the precision grid is the same everywhere, since a partition snapped at a different grid produces boundaries that no longer match its neighbours.
Caching is worth it when the same layer is repaired repeatedly across runs. Keying the repaired copy on the source snapshot digest and the repair parameters means a re-run reuses the work while a changed input or a changed grid correctly misses. This is the same digest-keyed derivation used for format conversion, and it composes: the working store can be repaired, reprojected and converted once, with the whole chain reproducible from the original.
Finally, report the repair rate as a first-class metric. A layer whose invalid fraction jumps from 0.3% to 4% between refreshes is telling you something happened upstream, and that signal is worth more than the repairs themselves — it is the difference between fixing the same defect weekly and getting the publisher to fix their export.
Integration With Downstream Stages
Everything after ingestion assumes valid geometry, and the assumption should be asserted rather than trusted. A single is_valid.all() check at the entry to the evaluation stage costs milliseconds and converts a class of silent wrongness into a loud failure.
The repair status travels with the parcel, too. A parcel repaired within budget is evaluated normally; one over budget carries a flag that the rule engine can use to mark its verdicts as provisional, and that the report renderer can surface. This is the same three-way honesty applied elsewhere in the pipeline: a parcel whose geometry had to be substantially reconstructed is not one to issue a confident violation against without a human looking.
Downstream stages that create geometry — buffers, envelopes, overlay fragments — should validate their own output as well. make_valid on an input does not guarantee that a negative buffer of it is valid, and an invalid envelope produces an encroachment measurement that is quietly meaningless.
Troubleshooting
make_validreturns a GeometryCollection. A bow tie resolved into two polygons plus a line. Keep the polygonal parts, discard the lineal ones, and record that you did; taking the largest part silently loses area.- Repair changes area by exactly half. Classic self-intersection, where the two lobes had opposite orientation and were cancelling. The repaired area is the correct one, and the original was never usable.
- A parcel becomes empty after repair. The input was a degenerate sliver with no interior. This is a missing-data case, not a repair case; quarantine it rather than accepting an empty geometry into the working store.
- Validity passes but overlays produce slivers. The defect is in the pair, not the geometry. Run the adjacency checks described above;
is_validwill never find it. - Repairs differ between two machines. GEOS versions differ. Pin the geospatial stack and record the versions in the run manifest, as covered in provenance and lineage tracking.
Related
Part of: Core Geospatial Compliance Architecture & Regulatory Mapping
- Fixing invalid parcel polygons with make_valid — the repair operation in detail.
- Snapping slivers between adjacent parcels — the pairwise defects validity cannot see.
- Handling edge cases in parcel boundary alignment — deriving lot lines from repaired boundaries.
- Zoning layer ingestion strategies — the stage this repair pass belongs to.
- Validation log design — where repair records are written.
Conclusion
Geometry repair is not a data-cleaning chore that happens once during onboarding; it is a recurring condition the architecture has to hold an opinion about. Repair once, at ingest, in the authoritative frame. Bound the change with a stated area-delta budget and route what exceeds it to a person. Record the reason, the operation and the delta for every feature touched. Done that way, the pipeline can answer the only question a reviewer really has about repaired geometry — what changed, by how much, and whether it could have moved this verdict — without anybody re-running a thing.