Invalid geometry is the quietest failure mode in a spatial database. A polygon whose boundary crosses itself is stored without complaint, indexed by GiST without complaint, and returned by bounding-box filtering without complaint. The problem surfaces weeks later, in a nightly aggregation that raises TopologyException: found non-noded intersection, or worse, in a report whose numbers are simply wrong. This page — part of Mastering Core Spatial Query Patterns — covers the full lifecycle: finding invalid geometry, understanding what each validity failure actually means, repairing it without destroying the data, and putting a constraint in place so it cannot come back.

Where invalid geometry does and does not matter Operations split into two groups. Bounding-box operators, GiST index scans, ST_Extent and ST_AsGeoJSON all work on invalid geometry because they only read coordinates or the envelope. Exact predicates and overlay functions such as ST_Intersects, ST_Union, ST_Intersection and ST_Area either raise an exception or return an answer that cannot be trusted. Invalid geometry passes silently through half the API Works anyway geom && envelope GiST index scan ST_Extent, ST_Envelope ST_AsGeoJSON, ST_AsText these read coordinates, not topology Fails or lies ST_Intersection, ST_Union ST_Difference, ST_Buffer ST_Area, ST_Perimeter ST_Contains on the exact pass these need a coherent boundary A self-intersecting polygon has an area that depends on which side of the crossing you count — GEOS has to pick one.

Prerequisites and Infrastructure Validation

Validity work needs nothing beyond a standard PostGIS install, but it is worth confirming the GEOS version first, because the repair algorithms changed materially in GEOS 3.10:

sql
SELECT postgis_full_version();
-- POSTGIS="3.4.2" GEOS="3.12.1-CAPI-1.18.1" PROJ="9.3.1" ...

GEOS 3.10 introduced the structure method for ST_MakeValid, which produces far cleaner output on real-world polygon data than the original linework method. If your GEOS is older than 3.10, ST_MakeValid accepts no method argument at all and always uses linework. Check before writing repair code that depends on the newer behaviour.

You will also want postgis_topology only if you intend to use the topology extension’s own validation — for ordinary geometry columns it is unnecessary:

sql
SELECT extname, extversion FROM pg_extension WHERE extname LIKE 'postgis%';

Finally, confirm which columns are actually at risk. Point and linestring columns can be invalid only in narrow ways — a point never is, and a linestring is invalid only if it has fewer than two distinct vertices. The interesting cases are all polygonal:

sql
SELECT f_table_name, f_geometry_column, type, srid
FROM geometry_columns
WHERE type ILIKE '%POLYGON%'
ORDER BY f_table_name;

Core Execution Workflow

Step 1 — Audit, and record the reason

ST_IsValid returns a boolean, which is not enough to act on. ST_IsValidDetail returns a composite value with the reason and the exact location of the failure, and that is what makes the audit useful:

sql
CREATE TABLE parcel_validity AS
SELECT
    p.id,
    (d).valid                       AS is_valid,
    (d).reason                      AS reason,
    ST_AsText((d).location)         AS location,
    ST_NPoints(p.geom)              AS vertices
FROM parcels p,
     LATERAL ST_IsValidDetail(p.geom) AS d
WHERE NOT (d).valid;

CREATE INDEX ON parcel_validity (reason);

Running this over a large table is expensive — see the FAQ — so materialise the result rather than re-running the check every time somebody asks how bad the problem is.

The distribution of reasons tells you what kind of repair you need:

sql
SELECT reason, count(*) AS n
FROM parcel_validity
GROUP BY reason
ORDER BY n DESC;

--            reason            |   n
-- -----------------------------+-------
--  Self-intersection           | 4,118
--  Ring Self-intersection      |   902
--  Duplicate Rings             |   214
--  Hole lies outside shell     |    41
--  Too few points in geometry  |     6
What each validity reason looks like Four small diagrams. A self-intersection is a bowtie where the outer ring crosses itself. A ring self-intersection is a boundary that touches itself at a single point, pinching the polygon. Duplicate rings are two identical inner rings stacked on each other. A hole outside the shell is an inner ring drawn entirely beyond the outer boundary. Four failures, four different repairs Self-intersection the bowtie Ring self-intersection pinched at one vertex Duplicate rings the same hole twice Hole outside shell usually a coordinate error A bowtie repairs cleanly into two polygons. A hole outside its shell is data corruption — repairing it hides a bug upstream.

Step 2 — Repair, choosing the right method

ST_MakeValid has two strategies and they produce genuinely different results:

sql
-- linework (default): keeps every input vertex, may split the polygon into pieces
SELECT ST_MakeValid(geom) FROM parcels WHERE id = 4182;

-- structure: reconstructs the area, discards degenerate linework
SELECT ST_MakeValid(geom, 'method=structure') FROM parcels WHERE id = 4182;

For a cadastral parcel, structure is almost always what you want: it returns a clean polygon whose area matches the intent of the original, discarding the zero-width spikes and dangling edges that linework faithfully preserves as separate line geometries. For survey data where every vertex is legally meaningful, linework is the safer choice precisely because it throws nothing away.

Repair in bounded batches, never in one statement — the same discipline as any other large backfill on a live table:

python
import psycopg

REPAIR = """
    WITH batch AS (
        SELECT p.id
        FROM parcels p
        JOIN parcel_validity v ON v.id = p.id
        WHERE p.id > %(after)s
        ORDER BY p.id
        LIMIT %(size)s
    )
    UPDATE parcels p
    SET geom = ST_CollectionExtract(
                   ST_MakeValid(p.geom, 'method=structure'), 3)
    FROM batch b
    WHERE p.id = b.id
    RETURNING p.id
"""

def repair_all(dsn: str, size: int = 2_000) -> int:
    after, total = 0, 0
    with psycopg.connect(dsn) as conn:
        while True:
            with conn.cursor() as cur:
                cur.execute(REPAIR, {"after": after, "size": size})
                ids = [row[0] for row in cur.fetchall()]
            conn.commit()                      # one transaction per batch
            if not ids:
                return total
            after, total = max(ids), total + len(ids)

ST_CollectionExtract(..., 3) keeps only the polygonal components, which is what makes the result assignable back into a geometry(Polygon, 4326) column. Without it, a repair that produces a GeometryCollection fails the column’s type constraint and the whole batch rolls back.

Step 3 — Verify the repair did not change the data

A repair that silently halves a parcel’s area is worse than the original problem. Compare before and after:

sql
SELECT
    count(*)                                              AS repaired,
    count(*) FILTER (WHERE NOT ST_IsValid(geom))          AS still_invalid,
    count(*) FILTER (WHERE ST_GeometryType(geom)
                           <> 'ST_Polygon')               AS wrong_type,
    round(max(abs(ST_Area(geom) - v.area_before)
              / nullif(v.area_before, 0))::numeric, 4)    AS worst_area_drift
FROM parcels p
JOIN parcel_validity v ON v.id = p.id;

An area drift above a percent or so on any row deserves a look by hand. Small drifts are expected — removing a zero-width spike removes no area at all, but resolving a bowtie genuinely changes which region the polygon describes.

What “Valid” Actually Means

The OGC Simple Features rules that GEOS enforces are narrower than most people assume, and knowing the boundaries saves a lot of wasted debugging.

A point is always valid. There is no arrangement of one coordinate pair that violates anything, so a validity audit over a point table always returns zero and running one is a waste of an afternoon. The failures people attribute to invalid points — a longitude of 200, a NaN coordinate, a point at the origin standing in for missing data — are all range problems, not validity problems, and need their own checks.

A linestring is valid if it has at least two distinct vertices. Notably, a linestring is allowed to cross itself: a figure-of-eight road centreline is perfectly valid and every function will handle it correctly. If your model requires simple (non-self-crossing) linework, that is ST_IsSimple, a different predicate with a different meaning.

A polygon carries the real rules, and there are five of them: the rings must be closed, the exterior ring must not self-intersect, interior rings must lie inside the exterior ring, interior rings must not overlap one another, and the interior must be connected — a polygon pinched into two lobes by a hole that touches the boundary at two points is invalid even though every ring is individually fine.

A multipolygon adds one more: its member polygons may touch at a point but must not overlap in area. This is where imported administrative boundaries most often fail, because a shared border digitised twice at slightly different precisions produces a sliver of overlap invisible at any zoom a human would look at.

Two consequences follow. First, ST_IsValid is a polygonal concern in practice, which is why the audit query on this page filters geometry_columns to polygonal types. Second, “valid” is not the same as “sensible”: a polygon can be perfectly valid and still have zero area, a duplicated vertex, or coordinates in the wrong hemisphere. Validity is a floor, not a quality standard, and the checks that catch the rest — extent constraints, area bounds, vertex counts — belong alongside it rather than instead of it.

Performance Considerations

Validity checking is an overlay-class operation, not an index-class one. Expect it to cost roughly what ST_Intersection costs on the same geometry, which means vertex count dominates everything:

Validity checking cost against vertex count Two curves against vertices per geometry. The bounding-box test is flat regardless of complexity because it reads only the envelope. ST_IsValid grows faster than linearly, reaching about four milliseconds per geometry at ten thousand vertices. Per-geometry cost by vertex count 0 4 ms ST_IsValid geom && envelope — flat 10 1,000 10,000 vertices On four million parcels averaging 380 vertices, a full audit is about ninety minutes of CPU. Store the result: re-auditing only rows whose geometry changed turns the next run into seconds.

Two practical consequences follow. First, run the audit on a replica if you have one — it is a pure read and the result is a small table you can copy back. Second, add a geom_checked_at timestamptz column and only re-check rows where xmin indicates a newer transaction, or simply where a trigger cleared the timestamp. The audit then becomes incremental instead of a recurring ninety-minute job.

Where Invalid Geometry Comes From

Knowing the sources is what turns a repair project into a prevention project. Four account for almost everything seen in practice.

Digitising and simplification. A polygon drawn by hand at one zoom level and then simplified for storage can cross itself where two vertices were close together. ST_Simplify in particular is documented as capable of producing invalid output on polygons; ST_SimplifyPreserveTopology exists precisely because of this and should be the default in any pipeline that simplifies areal data.

Format round-trips. Shapefiles have no notion of ring orientation being meaningful and store polygons with a convention that other formats interpret differently. A shapefile that round-trips through a tool with a different assumption emerges with inverted rings, which reads as holes outside their shells.

Coordinate precision. Two adjacent parcels digitised independently share a border in principle but not in floating-point reality. When they are unioned or clipped, the sliver between them becomes a self-intersection at a scale of nanometres — invisible, and fatal to any overlay.

Buffer and overlay output. ST_Buffer with a large negative distance can collapse a polygon into a self-touching shape. Overlay operations on already-invalid inputs propagate and amplify the problem, which is why the constraint on the input table matters more than any amount of checking downstream.

None of these is a bug in PostGIS, and none is fixed by better repair. They are fixed at the boundary — by validating what enters, and by choosing the topology-preserving variant of every function that has one.

Common Failure Modes and Fixes

The repair fails the column type constraint. ST_MakeValid on a badly self-intersecting polygon can return a MultiPolygon or a GeometryCollection. Wrap it in ST_CollectionExtract(..., 3) and, if the column is typed Polygon rather than MultiPolygon, decide explicitly what to do with genuinely multi-part results — usually widening the column type is the honest answer.

Repaired geometry is still invalid. This happens on geometries with coordinate values so close together that the repair reintroduces a crossing at floating-point precision. ST_SnapToGrid(geom, 0.000001) before the repair usually resolves it by collapsing near-duplicate vertices, at the cost of a millimetre of precision.

The audit table goes stale immediately. If writes continue during the audit, rows repaired yesterday are invalid again today because the import path never changed. Fix the import first — see enforcing validity with check constraints — and only then repair history.

A TopologyException appears in an unrelated query. GEOS reports the exception from the operation, not from the row, so the message names an aggregate rather than a parcel id. Wrap the aggregate in a per-row ST_IsValid filter temporarily to find which input is at fault, then repair that row.

Verification

The audit is complete when all three of these return zero:

sql
-- 1. no invalid geometry remains
SELECT count(*) FROM parcels WHERE NOT ST_IsValid(geom);

-- 2. no geometry has the wrong type after repair
SELECT count(*) FROM parcels WHERE ST_GeometryType(geom) <> 'ST_Polygon';

-- 3. the constraint that prevents recurrence exists and is validated
SELECT conname, convalidated
FROM pg_constraint
WHERE conrelid = 'parcels'::regclass AND conname = 'parcels_geom_valid';

The third is the one that matters long term. An audit without a constraint is a chore you will repeat every quarter; an audit with a constraint is a one-off.

Frequently Asked Questions

Do invalid geometries always cause query errors?

No, and that is the danger. Bounding-box operators and the GiST index work on any geometry, so an invalid polygon is indexed and returned by && exactly like a valid one. The failures appear later, in exact predicates and overlay operations, which can raise a TopologyException, return a wrong answer, or silently produce an empty result.

Does ST_MakeValid preserve the geometry type?

Not necessarily. Repairing a self-intersecting polygon can produce a MultiPolygon or a GeometryCollection. Wrap the repair in ST_CollectionExtract with the dimension you expect, and verify the type after repair rather than assuming it survived.

Should I repair on import or repair in the database?

Repair on import, and add a constraint so the database enforces the rule. Repairing after the fact means every consumer between import and repair saw the bad data, and the repair becomes an unbounded UPDATE over a large table.

How expensive is a validity check on a large table?

ST_IsValid runs the full GEOS validity algorithm per geometry, so it costs roughly what an overlay operation costs. On tens of millions of complex polygons, run it in batches or on a replica and store the result so repeat audits only re-check rows that changed.