Problem Statement

The validity audit has produced five thousand invalid parcels grouped by reason, and the obvious next step — wrap every one in ST_MakeValid and move on — is where most repair projects go wrong. This page covers the part that matters: choosing the repair method deliberately, keeping the output assignable to the column, and proving the repair did not change what the row means.

Why the Naive Approach Fails

A bare UPDATE parcels SET geom = ST_MakeValid(geom) WHERE NOT ST_IsValid(geom) fails in two distinct ways, and both of them are silent.

The first is a type failure that is not silent at all but is misleading: the statement aborts with Geometry type (GeometryCollection) does not match column type (Polygon), halfway through, having repaired nothing. Frustrating, but at least visible.

The second is worse. Where the repair succeeds, it may have quietly reinterpreted the polygon:

The same bowtie, repaired two ways Three panels. The input is a bowtie polygon whose outer ring crosses itself once. The linework repair returns two separate triangles as a MultiPolygon, preserving every vertex. The structure repair returns a single polygon covering the areal interpretation, dropping the crossing point. One input, two defensible answers input — invalid Self-intersection at the crossing method=linework MultiPolygon of two parts method=structure one polygon, larger area Neither is wrong. Which one is correct depends entirely on what the polygon was supposed to represent.

If those parcels feed an area-based tax calculation, the difference between the two repairs is money. The repair method is a domain decision, not a technical one, and it belongs in the migration alongside a note explaining the choice.

Production-Ready Implementation

The repair statement carries three defensive layers: the method choice, the collection extraction, and an area guard that refuses repairs that move too far.

sql
-- Repair a batch, but only accept results within 1% of the original area.
WITH batch AS (
    SELECT p.id, p.geom, ST_Area(p.geom) AS area_before
    FROM parcels p
    JOIN parcel_validity v ON v.id = p.id AND NOT v.is_valid
    WHERE p.id > %(after)s
    ORDER BY p.id
    LIMIT %(size)s
),
repaired AS (
    SELECT
        b.id,
        b.area_before,
        ST_CollectionExtract(
            ST_MakeValid(b.geom, 'method=structure'), 3
        )::geometry(MultiPolygon, 4326) AS geom_fixed
    FROM batch b
),
judged AS (
    SELECT
        r.*,
        ST_Area(r.geom_fixed)                                  AS area_after,
        abs(ST_Area(r.geom_fixed) - r.area_before)
            / nullif(r.area_before, 0)                         AS drift
    FROM repaired r
)
UPDATE parcels p
SET geom = j.geom_fixed
FROM judged j
WHERE p.id = j.id
  AND j.drift <= 0.01                     -- accept only small drift
RETURNING p.id, j.drift;

Rows rejected by the drift guard stay invalid on purpose. Collect them for review rather than forcing them through:

sql
INSERT INTO parcel_repair_quarantine (id, area_before, area_after, drift, noted_at)
SELECT j.id, j.area_before, j.area_after, j.drift, now()
FROM judged j
WHERE j.drift > 0.01
ON CONFLICT (id) DO NOTHING;

The Python driver around this is the same batched, resumable loop used elsewhere in this section; the only addition is reporting the two outcomes separately, because a run that repairs nine hundred rows and quarantines eleven is a success, while one that quarantines nine hundred means the method choice is wrong.

python
def repair_batch(conn, after: int, size: int = 1_000) -> tuple[int, int, int]:
    """Repair one batch. Returns (new_watermark, repaired, quarantined)."""
    with conn.cursor() as cur:
        cur.execute(REPAIR_SQL, {"after": after, "size": size})
        rows = cur.fetchall()                      # (id, drift) for accepted
        cur.execute(QUARANTINE_SQL, {"after": after, "size": size})
        quarantined = cur.rowcount
    conn.commit()

    if not rows and not quarantined:
        return after, 0, 0
    return max(r[0] for r in rows) if rows else after, len(rows), quarantined
Area drift across five thousand repairs A histogram of area change after repair. The overwhelming majority of rows show effectively zero drift, a small group shifts by under a percent, and a tail of forty rows moves by more than ten percent. The acceptance threshold sits at one percent, and everything beyond it is quarantined for review. Most repairs change nothing — the tail is what needs eyes 4,610 312 89 28 12 accept below 1% 0 <0.1% <1% <10% >10% Forty rows out of five thousand need a human. That is a morning's work, and it is the difference between a repair you can defend in an audit and one you cannot.

Repairing without an UPDATE

Sometimes the right move is not to change the stored geometry at all. If the invalid rows are a small minority and the only consumer that cares is a nightly overlay job, repairing at read time keeps the source data exactly as the partner supplied it:

sql
CREATE OR REPLACE VIEW parcels_clean AS
SELECT
    id,
    owner_id,
    CASE WHEN ST_IsValid(geom) THEN geom
         ELSE ST_CollectionExtract(ST_MakeValid(geom, 'method=structure'), 3)
    END AS geom
FROM parcels;

The cost is real — every read of the view evaluates ST_IsValid on every row, and the view cannot use the underlying GiST index for the repaired expression — so this is a pattern for small tables and analytical consumers, not for an API path. Its virtue is that the original bytes remain available for the day someone asks why a boundary moved, which is a question that surfaces in cadastral work with some regularity.

A middle option is a generated column holding the repaired geometry alongside the original, indexed for reads while the source stays untouched. That doubles storage for the geometry, which on a polygon table is the dominant cost, so it earns its place only where provenance genuinely matters.

Configuration and Tuning Knobs

method=structure requires GEOS 3.10 or newer. On older builds the argument is rejected outright, so guard for it rather than discovering it in production:

sql
SELECT postgis_geos_version();     -- '3.12.1-CAPI-1.18.1'

ST_SnapToGrid before the repair is the standard remedy for geometries that stay invalid after ST_MakeValid. A grid size of 0.000001 degrees — roughly ten centimetres — collapses near-duplicate vertices that keep reintroducing a crossing at floating-point precision. Apply it only to the rows that need it, since it is lossy by design.

The drift threshold is a domain parameter. One percent is a reasonable default for parcels; for building footprints where a small area matters more, tighten it to a tenth of a percent; for coarse land-cover polygons, five percent may be entirely acceptable. Write the chosen number and the reason into the migration.

Outcomes of one repair run Of 5,051 invalid parcels, 4,922 were repaired within the drift tolerance, 89 were quarantined for exceeding it, and 40 could not be repaired at all because the geometry was degenerate. The three counts are reported separately because they need different follow-up. Report the three outcomes separately repaired 4,922 — accepted, drift under 1% quarantined 89 — area moved too far, needs a human unrepairable 40 — degenerate input, fix it upstream A run that reports one aggregate number hides the two categories that actually need attention.

Verification Steps

sql
-- nothing invalid left outside quarantine
SELECT count(*)
FROM parcels p
LEFT JOIN parcel_repair_quarantine q ON q.id = p.id
WHERE NOT ST_IsValid(p.geom) AND q.id IS NULL;

-- total area is stable across the whole table
SELECT round(sum(ST_Area(geom))::numeric, 2) AS total_area_now
FROM parcels;

Compare that total against the value recorded before the repair began. A change of a few square metres across forty million parcels is rounding; a change of several hectares means a bowtie somewhere was resolved the wrong way and the drift threshold was too loose.

Gotchas Checklist

  • ST_CollectionExtract takes a dimension, not a type name. The argument is 1 for points, 2 for lines and 3 for polygons. Passing 'POLYGON' is a type error, and passing 2 on a polygon repair silently returns an empty geometry.
  • An empty result is not a failure the database will report. ST_CollectionExtract on a repair that produced only linework returns MULTIPOLYGON EMPTY, which is valid, assignable, and completely wrong. Add AND NOT ST_IsEmpty(geom_fixed) to the update predicate.
  • Repair changes the bounding box, so the index must be maintained. That happens automatically, but on a large repair run it means substantial index churn — expect bloat and plan a REINDEX CONCURRENTLY afterwards.
  • ST_MakeValid is not idempotent across methods. Repairing with linework and then with structure gives a different result from structure alone. Pick one method per table and stay with it.
  • Quarantined rows still fail any constraint you add later. Resolve or delete them before validating a CHECK (ST_IsValid(geom)) constraint, or the validation fails and takes the lock for nothing.