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:
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.
-- 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:
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.
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), quarantinedRepairing 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:
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:
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.
Verification Steps
-- 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_CollectionExtracttakes a dimension, not a type name. The argument is1for points,2for lines and3for polygons. Passing'POLYGON'is a type error, and passing2on a polygon repair silently returns an empty geometry.- An empty result is not a failure the database will report.
ST_CollectionExtracton a repair that produced only linework returnsMULTIPOLYGON EMPTY, which is valid, assignable, and completely wrong. AddAND 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 CONCURRENTLYafterwards. ST_MakeValidis not idempotent across methods. Repairing withlineworkand then withstructuregives a different result fromstructurealone. 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.
Related Topics
- Geometry Validity and Repair — parent topic: audit, repair and prevention together
- Detecting Invalid Geometries at Scale — where the repair queue comes from
- Enforcing Validity With Check Constraints — making the repair permanent
- Detecting GiST Index Bloat — cleaning up after a large repair run