Automated Geometry Remediation

Invalid geometry is not an edge case in production spatial pipelines — it is a steady baseline. Digitising errors, coordinate-precision truncation during format conversion, and lossy reprojection all produce self-intersecting rings, unclosed polygons, slivers, and near-duplicate vertices at a predictable rate. Detecting those defects is only half the job; a mature pipeline also repairs the ones it safely can, without a human in the loop, while refusing to touch the ones it cannot. This stage of the validation pipeline architecture turns the invalid features surfaced by earlier checks into either repaired, provably valid geometry or clearly quarantined records — and it records exactly what it changed. GIS analysts, QA engineers, and data stewards who own that boundary between “fix automatically” and “escalate to a human” are the audience here.


Automated Geometry Remediation Flow Invalid features enter a defect classifier that labels each as self-intersection, unclosed ring, sliver, or near-duplicate vertex. A policy router sends deterministic defects to a repair method and ambiguous ones to Quarantine. Repaired geometry passes an area-loss guard and validity re-check; passes go to Output while failures fall back to Quarantine. Every repair writes to an append-only audit ledger. Invalid features Defect Classifier label each defect Policy Router fix vs quarantine auto ambiguous Repair Method MakeValid · snap close ring · dedupe Guard + Verify area delta · IsValid idempotency pass Output valid layer exceeds guard Quarantine steward review Append-only Repair Audit Ledger feature id · method · area delta · hashes · run id

Prerequisites

Confirm each item before enabling automated repair on a production dataset. An unguarded repair stage is capable of destroying more data than the defects it fixes.

  1. PostGIS 3.2+ or Shapely 2.0+. ST_MakeValid gained a method parameter (linework versus structure) and a keepcollapsed option in PostGIS 3.2, backed by GEOS 3.10+. The ST_MakeValid deep-dive covers those parameters in full. On the Python side, shapely.make_valid requires Shapely 2.0+ and GEOS 3.8+.

  2. A validity diagnostic upstream. Remediation consumes the output of a detection stage. The geometry validity checks for vector data and Shapely geometry checks pages produce the per-feature is_valid flag and reason string this stage routes on. Never run a blind repair over an entire table.

  3. A canonical coordinate reference system (CRS) with a documented tolerance. Snap-to-tolerance and precision-reduction operations only make sense in a projected CRS whose units are metres or feet. A snap tolerance expressed in degrees on an EPSG:4326 layer will behave wildly differently near the poles than at the equator.

  4. A quarantine table and an append-only audit ledger. Before the first repair runs, the destination for rejected features and the immutable log of applied fixes must exist. Retrofitting an audit trail after a repair has already mutated a table means the earliest changes are unrecoverable.

  5. A backup or point-in-time recovery window. Automated UPDATE statements that rewrite geometry columns are not reversible from the table alone once the audit ledger and backups are both absent.


Conceptual Foundation

Remediation is a routing problem before it is a repair problem. The naive approach — run ST_MakeValid over every invalid row and commit — conflates two very different situations. A polygon with a single self-touching vertex has exactly one correct repaired form, and automating it is safe. A parcel boundary that has collapsed into a zero-width sliver has no unambiguous repair; any “fix” invents geometry that was never surveyed. Treating both the same way is how automated pipelines quietly corrupt legal records.

The design that holds up in production separates four responsibilities:

Classification. A diagnostic pass labels every invalid feature by defect class. GEOS reason strings — Self-intersection, Ring Self-intersection, Too few points in geometry component, Holes are nested — are the raw signal, supplemented by geometry-shape heuristics such as area-to-perimeter ratio for sliver detection. Classification is pure inspection; it changes nothing.

Policy routing. An explicit policy table maps each defect class to an action: apply a named repair method, or quarantine for steward review. Encoding this as data rather than scattered if statements makes the auto-fix versus quarantine boundary reviewable and lets stewards adjust thresholds without a code change. This is the same discipline that governs categorizing and prioritizing spatial errors — severity and remediability are two axes of the same triage.

Guarded repair. The chosen method runs inside a guard that captures the pre-repair state, applies the fix, and validates the result against three gates: the geometry must become valid, its area must not change beyond a configured fraction, and its feature count and dimension must be preserved unless the policy explicitly permits a change. Any gate failure reverts the feature to quarantine.

Idempotency and audit. A correct repair reaches a stable fixed point — running it again yields identical geometry. Every applied fix writes one immutable record to an append-only ledger. Together these make the stage safe to retry and possible to reconstruct after the fact.

The repair methods themselves fall into a small, well-understood set. ST_MakeValid resolves self-intersections and ring-structure violations. Snap-to-tolerance operations — covered in depth in snapping vertices to tolerance in PostGIS — align shared boundaries and remove near-duplicate vertices. Ring closure repairs unclosed polygons. Sliver removal drops sub-threshold artefacts. Each is deterministic, and each has a defect class it is the correct answer for and several it is not.


Step-by-Step Implementation

Step 1: Classify the Defect Before Repairing

Repair decisions depend on knowing why a feature is invalid. Build a classifier that maps GEOS reason strings and shape metrics to a defect class.

import geopandas as gpd
import shapely


def classify_defect(gdf: gpd.GeoDataFrame, sliver_ratio: float = 0.02) -> gpd.GeoDataFrame:
    """
    Label each feature with a defect_class. Pure inspection — no geometry is
    modified. Requires Shapely 2.0+.
    """
    out = gdf.copy()
    reason = shapely.is_valid_reason(out.geometry.values)
    valid = shapely.is_valid(out.geometry.values)

    # Shape metric for sliver detection: area over squared perimeter.
    # Genuine polygons have a compact ratio; slivers approach zero.
    perim = out.geometry.length.replace(0, 1e-9)
    compactness = out.geometry.area / (perim ** 2)

    def _label(is_valid, why, comp):
        if is_valid:
            return "valid"
        if "Self-intersection" in why or "Ring Self-intersection" in why:
            return "self_intersection"
        if "Too few points" in why:
            return "degenerate"
        if "Holes are nested" in why or "Interior is disconnected" in why:
            return "ring_structure"
        if comp < sliver_ratio:
            return "sliver"
        return "unclassified"

    out["defect_class"] = [
        _label(v, r, c) for v, r, c in zip(valid, reason, compactness)
    ]
    return out

Verification: run the classifier over a fixture containing one bowtie polygon, one three-point ring, and one known-good square; confirm the classes come back as self_intersection, degenerate, and valid respectively.

Step 2: Encode the Auto-Fix vs Quarantine Policy

Keep the decision in data, not code. A dictionary keyed by defect class maps to a method name or the sentinel "quarantine".

REPAIR_POLICY = {
    "self_intersection": "make_valid",   # deterministic, safe to automate
    "ring_structure":    "make_valid",
    "sliver":            "drop_sliver",  # remove below-threshold artefact
    "degenerate":        "quarantine",   # no unambiguous repair exists
    "unclassified":      "quarantine",   # unknown defect — never guess
}


def route(defect_class: str) -> str:
    """Return the method name for a defect class, defaulting to quarantine."""
    return REPAIR_POLICY.get(defect_class, "quarantine")

Verification: assert that route("degenerate") == "quarantine" and that any class absent from the table also returns "quarantine" — the policy must fail closed, never fail open.

Step 3: Apply the Repair Inside an Area-Loss Guard

The guard is the single most important safety mechanism in the stage. It converts a silent, data-destroying repair into an explicit, reviewable rejection.

import shapely


def guarded_make_valid(geom, max_area_loss: float = 0.02):
    """
    Repair a geometry and enforce three gates: validity, area preservation,
    and dimension preservation. Return (repaired_geom, accepted, area_delta).
    """
    original_area = shapely.area(geom)
    repaired = shapely.make_valid(geom)

    if not shapely.is_valid(repaired):
        return geom, False, None

    new_area = shapely.area(repaired)
    denom = original_area if original_area > 1e-9 else 1e-9
    area_delta = abs(original_area - new_area) / denom

    dimension_ok = (
        shapely.get_dimensions(repaired) == shapely.get_dimensions(geom)
    )

    accepted = area_delta <= max_area_loss and dimension_ok
    return (repaired, True, area_delta) if accepted else (geom, False, area_delta)

Verification: feed the guard a bowtie polygon and confirm it returns accepted=True with a small area_delta; feed it a polygon that make_valid collapses to a line and confirm the dimension gate returns accepted=False.

Step 4: Verify Idempotency

A repair that is not idempotent will drift under pipeline retries. Prove the fixed point by repairing twice and comparing.

import shapely


def is_idempotent(geom) -> bool:
    """
    A correct repair reaches a stable fixed point: repairing an already-valid
    geometry produces geometry equal to the input.
    """
    once = shapely.make_valid(geom)
    twice = shapely.make_valid(once)
    return shapely.equals(once, twice)

Verification: apply is_idempotent to the output of Step 3; it must return True for every accepted repair. A False result means the method is unstable and must not run unattended.

Step 5: Write an Immutable Repair Audit Record

Every applied fix produces exactly one ledger row. Hashing the before and after geometry gives a compact, comparable fingerprint for rollback and compliance.

import hashlib
import json
from datetime import datetime, timezone

import shapely


def audit_record(feature_id, defect_class, method, geom_before, geom_after,
                 area_delta, run_id):
    """Build one append-only audit row. Serialise to your ledger of choice."""
    def _hash(g):
        return hashlib.sha256(shapely.to_wkb(g)).hexdigest()[:16]

    return {
        "feature_id": str(feature_id),
        "defect_class": defect_class,
        "method": method,
        "geom_hash_before": _hash(geom_before),
        "geom_hash_after": _hash(geom_after),
        "area_delta": None if area_delta is None else round(float(area_delta), 6),
        "run_id": run_id,
        "repaired_at": datetime.now(timezone.utc).isoformat(),
    }

Verification: confirm two runs of the same repair on the same feature produce identical geom_hash_before and geom_hash_after values — proof that the ledger captures a deterministic operation.


Common Failure Modes & Fixes

Symptom Root cause Remediation
Repaired polygon silently became a MultiPolygon ST_MakeValid split a self-touching shell into parts Add a dimension-and-part-count gate to the guard; route type changes to quarantine unless policy allows them
Parcel area shrank by 30% after repair make_valid dropped a large invalid interior ring Tighten max_area_loss; the ST_MakeValid page shows the batch area-loss check
Shared borders drifted apart after snapping Snap tolerance larger than the smallest real feature dimension Reduce tolerance to below the minimum genuine vertex spacing; see snapping vertices to tolerance
Audit ledger fills with repeated repairs of the same feature Repair is not idempotent; retries re-apply it Add the idempotency check from Step 4; only write a ledger row when geometry actually changed
ST_MakeValid returns an empty geometry Input was degenerate (fewer than 4 points for a ring) Classify as degenerate and quarantine; empty output is not a valid repair
Sliver removal deleted a genuine narrow feature Compactness threshold too aggressive for the dataset Calibrate sliver_ratio against known-good narrow features before enabling auto-drop
Repair succeeded but downstream joins broke Repaired geometry changed winding order or vertex count Re-node shared boundaries after repair; verify with ST_IsValid and a topology re-check

Performance & Scale Considerations

Repair only what failed. The detection stage already produced an is_valid flag. Filter to WHERE NOT ST_IsValid(geom) (or the equivalent boolean column) before invoking any repair, so the expensive topology routine touches a small fraction of rows. Running ST_MakeValid across an entire multi-million-row table when 0.3% is invalid wastes almost all the compute.

Batch database repairs with a bounded UPDATE. A single UPDATE ... SET geom = ST_MakeValid(geom) over a huge table holds one long transaction and a table-wide lock. Instead, page through primary-key ranges in bounded batches, committing each, so lock duration stays short and progress survives interruption. The ST_MakeValid deep-dive gives a concrete batched-UPDATE template with an inline area-loss check.

Push set-level repairs to PostGIS, keep per-feature logic in Python. ST_MakeValid, ST_SnapToGrid, and ST_Node run inside the database against GiST-indexed geometry columns and outperform pulling millions of features into a Python process. Reserve the Shapely path for pipelines where repair must interleave with Python-native logic — external lookups, machine-learning scores, or the same in-memory rule engine described in building rule engines with GeoPandas.

Make the audit write cheap. Appending one ledger row per repaired feature is fine at thousands of features; at millions, buffer the records and bulk-insert them, and store geometry hashes rather than full WKB copies to keep the ledger compact.


Integration with the Validation Pipeline

Remediation sits downstream of detection and upstream of publication. Its input contract is a set of features already flagged invalid, each carrying a defect reason from the geometry validity checks or the Shapely diagnostic pass. Its output contract is two disjoint sets: provably valid features bound for the output layer, and quarantined features bound for steward review — plus one audit ledger that accounts for every change between input and output.

Feed the router from the error classifier. The severity tiers produced by categorizing and prioritizing spatial errors and the defect classes produced here are complementary: severity says how urgent a failure is, remediability says whether the pipeline can fix it unattended. A blocker-severity defect that is deterministically repairable should be auto-fixed and logged; a warning-severity defect that is ambiguous should still be quarantined.

Emit metrics per run. Record counts of features repaired by method, quarantined by defect class, and rejected by the area-loss guard. A rising quarantine rate on a previously stable feed is an early signal of upstream corruption — a broken export, a lossy reprojection, or a schema migration that mangled coordinates. Catching it at the remediation layer is far cheaper than discovering silently invented geometry in a published map.

Keep the ledger queryable. Compliance reviews and rollback both depend on being able to ask “what did the pipeline change, when, and by how much.” An append-only ledger keyed by feature identifier and run identifier answers that directly and is the audit backbone the whole stage exists to support.


Frequently Asked Questions

Should I repair invalid geometry automatically or quarantine it?

Automate repair only for deterministic, low-risk defect classes where the correct output is unambiguous — unclosed rings, sub-tolerance duplicate vertices, and simple self-intersections that ST_MakeValid resolves without changing area beyond a small threshold. Quarantine anything where the repair would change feature count, alter area beyond the guard, split a polygon into a multipolygon, or touch legally significant boundaries such as cadastral parcels. Encode the split as an explicit policy table so the auto-fix versus quarantine decision is auditable rather than buried in code.

Why is buffer(0) discouraged for repairing invalid polygons?

buffer(0) is a legacy side effect of the buffering algorithm rather than a purpose-built repair. It can silently drop interior rings, merge separate parts of a multipolygon, and shift vertices by tiny amounts, and its behaviour varies across GEOS versions. ST_MakeValid in PostGIS and shapely.make_valid call the dedicated topology repair routine, produce more deterministic output, and preserve polygon dimension when configured to do so. Reserve buffer(0) only for backwards compatibility with GEOS versions older than 3.8.

How do I stop a repair from destroying data through area loss?

Wrap every automated repair in an area-loss guard. Capture the pre-repair area, run the fix, capture the post-repair area, and compute the fractional delta. If the delta exceeds a configured threshold — commonly one to five percent for parcels — reject the repair, restore the original geometry, and route the feature to quarantine. The guard converts a silent data-destroying repair into an explicit, reviewable failure and is essential in cadastral and infrastructure datasets where geometry carries legal or financial weight.

Why does an automated repair stage need to be idempotent?

Pipelines retry. If a batch fails midway and reruns, or a message is redelivered from a queue, the repair stage may process the same feature twice. An idempotent repair reaches a stable fixed point on the first pass, so a second pass over already-valid geometry produces no change and writes no new audit record. Verify idempotency by re-running the validity check and confirming a second remediation call returns identical geometry. Without it, repeated runs can compound tiny coordinate shifts and inflate the audit ledger with phantom repairs.


Back to Validation Pipeline Architecture