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.

The repair pass, once, at ingestGeometry is snapped to a precision grid, validated, repaired where invalid, and routed by how much the repair changed the parcel’s area.Snap to a precision gridcollapses near-duplicate vertices deterministicallyClassify invalidity by reasonself-intersection, ring orientation, hole outside shellRepair and measure the area deltamake_valid, with before and after recordedRoute by the delta budgetwithin budget accepted; over budget to reviewWrite back, and log what changedoriginal preserved; repair record append-only
Repairing later, inside evaluation, means the same parcel can be repaired differently on two runs — which is the end of reproducibility.

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.
  • shapely 2.0+ with a GEOS build that exposes make_valid and set_precision, and geopandas 1.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.

Repairing at ingest versus repairing in evaluationThe same repair applied at two points in the pipeline, compared by determinism, auditability and cost.At ingestInside evaluationDeterminismEvery consumer sees one geometryDepends on which rule touched it firstCostOnce per layerOnce per rule, per parcelAuditOne repair record per featureRepairs scattered across the runSnapshotThe repaired layer is hashedThe hashed layer is not what was measured
There is only one placement that survives the question "did you change the parcel?"

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.

Area change by repair type, against a delta budgetTypical fractional area change for precision snapping, ring repair and bow-tie resolution, compared with a budget set below the tightest rule margin.Precision snapping at 1 mm0.0004% area changeDuplicate vertex removal0.002% area changeDelta budget (illustrative)0.05% area changeBow-tie resolution12% area changeA bow tie was never measurable in the first place; the repaired area is the correct one, and the parcel still deserves review.
Under the budget a repair cannot move a verdict. Over it, a person should look before the pipeline issues one.

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_valid returns 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_valid will 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.

Part of: Core Geospatial Compliance Architecture & Regulatory Mapping

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.