← 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;Key Parameters & Options
| Function | Returns | Use for |
|---|---|---|
ST_IsValid(geom) | boolean | The CHECK constraint |
ST_IsValidReason(geom) | text | Logging and quick diagnosis |
ST_IsValidDetail(geom) | (valid, reason, location) | API responses — the location is the valuable part |
ST_MakeValid(geom) | geometry | Bulk repair; may change the geometry type |
ST_IsValidDetail(geom, 1) | ESRI-compatible check | Data originating from ESRI tooling, which permits self-touching rings |
CHECK … NOT VALID | constraint | Enforce 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.
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.
Gotchas & Failure Modes
ST_MakeValidchanging the geometry type. Repairing a self-intersectingPolygonfrequently yields aMultiPolygon, which a typed column rejects withGeometry type (MultiPolygon) does not match column type (Polygon). Type the column as multi, or applyST_CollectionExtract(…, 3).- Validating after the insert. A
CHECKcatches it, but the client gets a database error rather than a useful message. Validate first, insert second. ST_IsValidon 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
locationfield. 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 itNOT VALID, clean the backlog, then validate. - Repair applied on read. Wrapping every query in
ST_MakeValidhides 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, ...}}Related
- Strict Pydantic Validation for Geometry — the validation layer this completes
- Validating WKT and GeoJSON with Pydantic v2 — the shape checks that run first
- Handling Async File Uploads for Shapefile Processing — where repair rather than rejection is the right policy
← Back to Strict Pydantic Validation for Geometry