Rejecting Invalid Polygons with ST_IsValid

Self-intersections, unclosed rings and holes outside their shell break ST_Intersects, ST_Area and every tile that touches them. Validate on write, report the exact failure location, and know when to repair instead.

← Back to Strict Pydantic Validation for Geometry

This page covers validating polygon topology at the API boundary: what invalidity actually means to PostGIS, how to report it usefully, and where the schema-level backstop belongs.

Context & When to Use

A polygon can be well-formed JSON, have the right number of coordinates, sit in the right coordinate system, and still be geometrically invalid. The common cases are a ring that crosses itself, a hole that lies outside its shell or overlaps another hole, a ring with fewer than four positions, or a first position that does not equal the last. All of them parse. None of them raise on insert into an untyped column.

The cost arrives later and somewhere else. ST_Intersects against an invalid polygon can be inconsistent depending on argument order. ST_Area may return a value that is meaningless. ST_SimplifyPreserveTopology, which every vector tile request calls, raises TopologyException and the tile fails — so a single bad row taken in on Tuesday breaks a map on Friday, and the stack trace points at the tile route rather than at the import.

Validate at the boundary, where the client is still present to be told what is wrong. The shape and range checks in Validating WKT and GeoJSON with Pydantic v2 run first and catch malformed input; this check runs after and catches input that is well-formed but geometrically impossible.

Runnable Implementation

from typing import Annotated, Any

import asyncpg
from fastapi import APIRouter, Depends, HTTPException

router = APIRouter(prefix="/v1/features", tags=["features"])

# ST_IsValidDetail returns (valid, reason, location) — everything a client needs
VALIDATE_SQL = """
SELECT (d).valid                        AS valid,
       (d).reason                       AS reason,
       ST_AsGeoJSON((d).location, 6)    AS location,
       ST_NPoints(g)                    AS vertices,
       GeometryType(g)                  AS geom_type
FROM  (SELECT ST_SetSRID(ST_GeomFromGeoJSON($1), 4326) AS g) s,
LATERAL (SELECT ST_IsValidDetail(s.g) AS d) v
"""

INSERT_SQL = """
INSERT INTO features (layer, geom)
VALUES ($1, ST_SetSRID(ST_GeomFromGeoJSON($2), 4326))
RETURNING id
"""


@router.post("")
async def create_feature(
    payload: dict[str, Any],
    pool: asyncpg.Pool = Depends(get_pool),
) -> dict[str, Any]:
    geometry_json = json_dumps(payload["geometry"])

    async with pool.acquire() as conn:
        check = await conn.fetchrow(VALIDATE_SQL, geometry_json)

        if not check["valid"]:
            # Name the reason AND the coordinates — "invalid geometry" is useless
            raise HTTPException(
                status_code=422,
                detail={
                    "error": "invalid_geometry",
                    "reason": check["reason"],          # e.g. "Self-intersection"
                    "at": json_loads(check["location"]) if check["location"] else None,
                    "vertices": check["vertices"],
                    "hint": "repair the ring locally, or POST to /v1/features:repair",
                },
            )

        feature_id = await conn.fetchval(INSERT_SQL, payload["layer"], geometry_json)

    return {"id": feature_id, "vertices": check["vertices"], "type": check["geom_type"]}

The schema-level backstop costs one constraint and covers every writer the API does not control:

ALTER TABLE features
  ADD CONSTRAINT features_geom_valid CHECK (ST_IsValid(geom)) NOT VALID;

-- Validate existing rows separately: NOT VALID applies the check to new rows
-- immediately and lets you fix the backlog without holding a long lock.
ALTER TABLE features VALIDATE CONSTRAINT features_geom_valid;
The four invalidity cases and what PostGIS calls themFour small polygon sketches. A bow-tie shape whose boundary crosses itself is reported as Self-intersection. A shell with a hole drawn entirely outside it is reported as Hole lies outside shell. Two holes that overlap each other are reported as Holes are nested. A ring whose last position does not repeat its first is reported at parse time as an unclosed ring. Each is annotated with the reason string that appears in the API response so a client can match on it.What invalid looks like, and what the response saysbow-tieSelf-intersectionlocation: the crossing pointhole outsideHole lies outside shelllocation: a hole vertexoverlapping holesHoles are nestedlocation: an intersectionunclosed ringcaught at parsebefore ST_IsValid runsReturning the reason string and the location turns a support ticket into a client-side fix — the coordinatepoints straight at the vertex the client needs to change.

Key Parameters & Options

FunctionReturnsUse for
ST_IsValid(geom)booleanThe CHECK constraint
ST_IsValidReason(geom)textLogging and quick diagnosis
ST_IsValidDetail(geom)(valid, reason, location)API responses — the location is the valuable part
ST_MakeValid(geom)geometryBulk repair; may change the geometry type
ST_IsValidDetail(geom, 1)ESRI-compatible checkData originating from ESRI tooling, which permits self-touching rings
CHECK … NOT VALIDconstraintEnforce for new rows without scanning the backlog

NOT VALID is the one to know when adding the constraint to a live table. It applies to every new write immediately and skips the full-table verification, so the lock is brief; run VALIDATE CONSTRAINT later, once the existing invalid rows have been dealt with.

Repair or reject?

The decision differs by path, and getting it backwards is a common source of both bad data and lost imports.

Repair or reject, by write pathFour write paths with their recommended policy. An interactive single-feature POST should reject, because the client is present and can fix the input. A bulk file import should repair with ST_MakeValid and record what changed, because rejecting the file loses everything. A migration from a legacy system should repair and produce a report, since the source cannot be corrected. A partner feed should reject and notify, because silently repairing another organisation's data hides a defect they need to fix at source.The policy depends on whether anyone can fix the sourceWrite pathPolicyWhyinteractive POSTreject 422the client is present and can fix itbulk file importrepair + logrejecting one row loses the filelegacy migrationrepair + reportthe source cannot be correctedpartner feedreject + notifysilent repair hides their defectWhenever repair is chosen, record the original alongside the fixed geometry — a repair is a data change.

When repairing, keep evidence:

INSERT INTO geometry_repairs (feature_id, reason, original_wkb, repaired_at)
SELECT id, ST_IsValidReason(geom), ST_AsBinary(geom), now()
FROM   features WHERE NOT ST_IsValid(geom);

UPDATE features SET geom = ST_MakeValid(geom) WHERE NOT ST_IsValid(geom);

What repair actually does to a shape

ST_MakeValid is not a cosmetic fix. It resolves invalidity by changing the geometry, and knowing which change it makes is the difference between an acceptable repair and a silent data loss.

Before and after ST_MakeValidThree transformations. A bow-tie polygon becomes a multipolygon of two triangles, changing the geometry type and doubling the part count. A polygon with a hole lying outside its shell becomes a multipolygon containing both the shell and the former hole as separate parts, which is rarely what the author intended. A polygon with a zero-width spike has the spike removed entirely, losing a vertex and a small amount of area. Each is annotated with whether the change is usually acceptable.What comes out the other sidePolygon → MultiPolygontype changed — column may rejecthole becomes a second partrarely what was intendedspike removedusually the right fixOnly the third case is unambiguously an improvement. The first two change what the feature means, which iswhy an interactive write should refuse rather than guess — and why a repair must always be recorded.

Gotchas & Failure Modes

  • ST_MakeValid changing the geometry type. Repairing a self-intersecting Polygon frequently yields a MultiPolygon, which a typed column rejects with Geometry type (MultiPolygon) does not match column type (Polygon). Type the column as multi, or apply ST_CollectionExtract(…, 3).
  • Validating after the insert. A CHECK catches it, but the client gets a database error rather than a useful message. Validate first, insert second.
  • ST_IsValid on a huge geometry inside a request. A coastline with 200 000 vertices takes tens of milliseconds. Acceptable once, expensive in a loop — batch bulk validation outside the request path.
  • Ignoring the location field. It is the single most useful thing in the response and costs nothing extra to return.
  • A constraint added without NOT VALID. The full-table verification holds a lock for the duration on a large table. Add it NOT VALID, clean the backlog, then validate.
  • Repair applied on read. Wrapping every query in ST_MakeValid hides the problem and pays the cost forever. Fix the data once, at write time.

Keeping the backlog from returning

Adding the constraint stops new invalid rows, but nothing stops the same upstream source producing them again through a path that bypasses the API. Track the rejection rate per source as a metric, and treat a rising rate as a data-quality signal to take back to whoever produces the file, rather than as noise to be filtered out. A partner feed that has produced self-intersecting parcels every month for a year is not a validation problem; it is a conversation nobody has had yet.

Keeping the backlog from returning

Adding the constraint stops new invalid rows, but nothing stops the same upstream source producing them again through a path that bypasses the API. Track the rejection rate per source as a metric, and treat a rising rate as a data-quality signal to take back to whoever produces the file, rather than as noise to be filtered out. A partner feed that has produced self-intersecting parcels every month for a year is not a validation problem; it is a conversation nobody has had yet.

Repairing on the client’s behalf is a last resort, and it is worth being honest with the client when it happens: return the repaired geometry in the response so the caller can see what was stored rather than assuming their payload was accepted verbatim.

Verification Snippet

-- Backlog check before adding the constraint
SELECT count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS invalid,
       count(*)                                     AS total
FROM   features;

-- What is wrong, and where
SELECT id, (ST_IsValidDetail(geom)).reason,
       ST_AsText((ST_IsValidDetail(geom)).location)
FROM   features WHERE NOT ST_IsValid(geom) LIMIT 5;
--  id  |      reason       |        st_astext
-- -----+-------------------+--------------------------
--  912 | Self-intersection | POINT(-0.1274 51.5069)
curl -s -X POST localhost:8000/v1/features -H 'content-type: application/json' -d '{
  "layer":"parcels",
  "geometry":{"type":"Polygon","coordinates":[[[0,0],[1,1],[1,0],[0,1],[0,0]]]}}' | jq
# {"detail":{"error":"invalid_geometry","reason":"Self-intersection",
#            "at":{"type":"Point","coordinates":[0.5,0.5]},"vertices":5, ...}}

← Back to Strict Pydantic Validation for Geometry