Repairing Invalid Geometries with ST_MakeValid

You have a PostGIS table where ST_IsValid returns false on some fraction of the polygons, and you need those features repaired in place — deterministically, without inventing coordinates, and without a repair quietly shrinking a parcel by half. This page is the PostGIS-specific procedure for that job: how ST_MakeValid actually works, when to reach for the linework versus structure method introduced in PostGIS 3.2, how to keep a repaired polygon from decaying into a line collection, and how to run the repair as a batched, area-guarded UPDATE. It is the database-side companion to the broader automated geometry remediation stage.


ST_MakeValid repair decision flow Invalid geometry enters ST_MakeValid, which runs either the linework method preserving all vertices or the structure method rebuilding a clean polygon. ST_CollectionExtract type 3 keeps only polygonal parts. An area-loss check accepts the repair to Output or rejects it to Quarantine, and ST_IsValid confirms the final result. Invalid ST_IsValid = f ST_MakeValid linework (keep verts) structure (rebuild) Extract type 3 keep polygonal parts Area-loss check pass Output ST_IsValid = t exceeds Quarantine

Prerequisites

  • PostGIS 3.2 or newer on GEOS 3.10+. The method and keepcollapsed parameters of ST_MakeValid only exist from PostGIS 3.2. Check with SELECT postgis_full_version(); and confirm the GEOS version string is at least 3.10. On older builds you get the single-argument linework-only form.
  • A geometry column in a projected CRS with metre or foot units. Area-loss checks compare ST_Area before and after; the numbers are only meaningful in a planar projected coordinate reference system (CRS), not in degrees.
  • A primary key on the target table. Batched, resumable repair keys off an ordered primary key. A table without one forces a single monolithic UPDATE, which this procedure specifically avoids.
  • A quarantine table with the same geometry type and CRS, ready to receive features that fail the area-loss gate or remain invalid after repair.
  • Gotcha: ST_MakeValid on the default linework method can return a GeometryCollection even when the input was a single Polygon. Plan for the ST_CollectionExtract step below, or a repaired row can silently violate a geometrytype constraint on the column.

Step-by-Step Procedure

Step 1 — Find invalid rows and read their reasons

Never repair blind. Inspect the defect distribution first so you know whether linework or structure fits.

-- Count invalid features and group by the underlying reason
SELECT
    left(ST_IsValidReason(geom), 40) AS reason,
    count(*)                          AS n
FROM parcels
WHERE NOT ST_IsValid(geom)
GROUP BY reason
ORDER BY n DESC;

Verification: the result lists reasons such as Self-intersection or Ring Self-intersection with counts. If the query returns zero rows, the table is already valid and no repair is needed. These reason strings are the same signal the geometry validity checks for vector data stage produces.


Step 2 — Choose the linework or structure method

Compare both on a sample before committing to one for the whole table.

-- Inspect how each method repairs the same invalid feature
SELECT
    ST_GeometryType(ST_MakeValid(geom))                                  AS default_linework_type,
    ST_GeometryType(ST_MakeValid(geom, 'method=structure'))              AS structure_type,
    ST_Area(geom)                                                        AS orig_area,
    ST_Area(ST_MakeValid(geom, 'method=structure'))                      AS structure_area
FROM parcels
WHERE NOT ST_IsValid(geom)
LIMIT 20;

Verification: default_linework_type is often ST_GeometryCollection for self-intersecting polygons, while structure_type stays ST_Polygon or ST_MultiPolygon. If structure_area closely matches orig_area, the structure method is the cleaner choice for polygon layers.


Step 3 — Preserve polygon dimension

Force the repaired output back to a pure polygonal type so a repair can never downgrade a polygon into loose lines or points.

-- Repair, keep only polygonal parts (type 3), and confirm the type is stable
SELECT
    id,
    ST_GeometryType(
        ST_CollectionExtract(
            ST_MakeValid(geom, 'method=structure'),
            3                       -- 1=points, 2=lines, 3=polygons
        )
    ) AS repaired_type
FROM parcels
WHERE NOT ST_IsValid(geom)
LIMIT 20;

Verification: every repaired_type is ST_Polygon or ST_MultiPolygon. ST_CollectionExtract(..., 3) strips any stray lines the repair produced; without it, a column constrained to POLYGON can reject the UPDATE.


Step 4 — Run a batched UPDATE with an area-loss check

Repair in bounded primary-key ranges. Each batch commits independently, and the area-loss guard skips repairs that would change a feature’s area beyond the threshold.

-- Repair one batch (id range) with an inline 2% area-loss guard.
-- Run repeatedly, advancing the id window, until no invalid rows remain.
WITH candidate AS (
    SELECT
        id,
        geom AS orig_geom,
        ST_CollectionExtract(
            ST_MakeValid(geom, 'method=structure'), 3
        ) AS fixed_geom
    FROM parcels
    WHERE NOT ST_IsValid(geom)
      AND id >= 0 AND id < 10000        -- batch window; advance each run
)
UPDATE parcels p
SET geom = c.fixed_geom
FROM candidate c
WHERE p.id = c.id
  AND ST_IsValid(c.fixed_geom)
  AND abs(ST_Area(c.orig_geom) - ST_Area(c.fixed_geom))
        / GREATEST(ST_Area(c.orig_geom), 1e-9) <= 0.02;

Verification: the statement reports the number of rows updated. Features whose area delta exceeds 2% are deliberately left untouched by this UPDATE — they remain invalid and are collected in the next step for quarantine. Advance the id window and repeat until Step 1 returns zero.


Step 5 — Verify with ST_IsValid and quarantine survivors

Confirm the repair reached every feature it should, and isolate the ones it could not safely fix.

-- Move any still-invalid features (guard rejected or unrepairable) to quarantine
WITH survivors AS (
    SELECT * FROM parcels WHERE NOT ST_IsValid(geom)
)
INSERT INTO parcels_quarantine (id, geom, reason, quarantined_at)
SELECT id, geom, ST_IsValidReason(geom), now()
FROM survivors;

-- Final gate: this must return 0 for the repaired set
SELECT count(*) AS remaining_invalid
FROM parcels
WHERE NOT ST_IsValid(geom)
  AND id NOT IN (SELECT id FROM parcels_quarantine);

Verification: remaining_invalid is 0. Any non-zero count means features are neither valid nor quarantined — investigate before publishing the layer.

Interpreting Results

The gap between ST_Area(orig_geom) and ST_Area(fixed_geom) is the primary signal. A near-zero delta means the repair only re-noded existing linework and is safe to accept automatically. A large delta means ST_MakeValid dropped a substantial ring or fragment — the structure method removing an invalid hole, or ST_CollectionExtract discarding a large non-polygonal part — and the feature belongs in quarantine for a human to inspect.

Watch the geometry-type transitions too. A Polygon that becomes a MultiPolygon after repair is legitimate when a self-touching shell splits into two genuine parts, but it changes how downstream area and adjacency logic behaves. Flag type changes even when the area-loss gate passes, mirroring the severity handling in categorizing and prioritizing spatial errors.

Gotchas & Edge Cases

The structure method silently discards linework. If your polygon layer legitimately carries embedded lines or points inside a GeometryCollection, method=structure will drop them. Confirm your data is purely polygonal before choosing it.

keepcollapsed changes what happens to degenerate parts. By default a collapsed ring is dropped. Passing 'method=structure keepcollapsed=true' retains a lower-dimension representation of a collapsed feature — occasionally useful for diagnostics, but it reintroduces the non-polygon output that Step 3 exists to remove. Do not combine it with a strict polygon column constraint.

Area comparison breaks in a geographic CRS. ST_Area on an EPSG:4326 geometry returns square degrees, which are meaningless for a percentage threshold. Reproject to a projected CRS, or use ST_Area(geom::geography) for a metre-based figure, before applying the guard.

Empty output is not a repair. Fully degenerate inputs — a “polygon” with three coincident points — can make ST_MakeValid return an empty geometry. ST_CollectionExtract then yields an empty polygon, which passes some checks but represents no feature. Add AND NOT ST_IsEmpty(c.fixed_geom) to the UPDATE guard to force these into quarantine.

A single monolithic UPDATE bloats the table. Rewriting every geometry in one transaction produces dead tuples equal to the number of updated rows and blocks autovacuum until it commits. Always batch, as Step 4 does.

When to Escalate

ST_MakeValid handles structural invalidity but not every geometry problem. Escalate when:

  • The defect is misaligned shared boundaries, not self-intersection. Adjacent parcels that fail to meet cleanly need snapping, not validity repair — see snapping vertices to tolerance in PostGIS.
  • Repairs must respect OGC topology rules across features. When “no two parcels may overlap” or “coverage must have no gaps” is the requirement, per-feature ST_MakeValid is insufficient; you need topology-aware processing grounded in understanding OGC topology rules.
  • The area-loss guard rejects a large share of features. A high rejection rate signals upstream corruption. Route these through the classification and quarantine flow in automated geometry remediation rather than loosening the threshold.

Related:

Back to Automated Geometry Remediation