Problem Statement
The repair run has finished and the table is clean. Six weeks later the audit finds four hundred new invalid parcels, because the nightly shapefile import never changed. This page closes the loop: adding a validity constraint to a table that is serving live traffic, without the multi-minute exclusive lock that makes people avoid constraints entirely.
Why the Naive Approach Fails
ALTER TABLE parcels ADD CONSTRAINT parcels_geom_valid CHECK (ST_IsValid(geom)); is one statement and it does exactly what you asked. It also takes an ACCESS EXCLUSIVE lock, holds it while it evaluates the full GEOS validity algorithm on all forty-one million rows, and blocks every reader and writer for the ninety minutes that takes.
There is also an ordering trap. If a single invalid row remains anywhere in the table, VALIDATE CONSTRAINT fails at whatever point it encounters it — after having scanned everything before it. Repair and quarantine must genuinely be complete first, which is why the audit table and the quarantine table exist.
Production-Ready Implementation
The full sequence, with the lock guard that stops a blocked ALTER from queueing the whole table behind it:
-- Step 1: prove there is nothing left to violate the constraint.
SELECT count(*) AS violations
FROM parcels
WHERE geom IS NOT NULL AND NOT ST_IsValid(geom);
-- must be 0 before continuing
-- Step 2: add the constraint NOT VALID, with a bounded lock wait.
BEGIN;
SET LOCAL lock_timeout = '3s';
ALTER TABLE parcels
ADD CONSTRAINT parcels_geom_valid
CHECK (geom IS NULL OR ST_IsValid(geom)) NOT VALID;
COMMIT;
-- Step 3: validate history, under a lock that lets traffic through.
SET lock_timeout = '3s';
ALTER TABLE parcels VALIDATE CONSTRAINT parcels_geom_valid;If step 2 times out, nothing has changed and the statement can simply be retried — that is the entire point of the timeout. A retry loop belongs in the migration runner:
import time
import psycopg
from psycopg import errors
DDL = """
ALTER TABLE parcels
ADD CONSTRAINT parcels_geom_valid
CHECK (geom IS NULL OR ST_IsValid(geom)) NOT VALID
"""
def add_constraint(dsn: str, attempts: int = 20) -> None:
"""Add the constraint, retrying while other transactions hold the table."""
for attempt in range(1, attempts + 1):
try:
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("SET lock_timeout = '3s'")
cur.execute(DDL)
conn.commit()
return
except errors.LockNotAvailable:
wait = min(2 ** attempt, 60)
print(f"lock busy, retrying in {wait}s (attempt {attempt})")
time.sleep(wait)
raise RuntimeError("could not acquire the lock after 20 attempts")Note the geom IS NULL OR clause. Without it the constraint also enforces NOT NULL, which is a separate decision and one that will surprise whoever adds a row that legitimately has no geometry yet — for example during the add-column phase of a migration.
The three constraints worth having together
Validity is one of three properties worth pinning down, and they compose:
What the error message says, and what to do about it
A constraint violation produces a message naming the constraint and nothing about the geometry:
ERROR: new row for relation "parcels" violates check constraint "parcels_geom_valid"
DETAIL: Failing row contains (91824, 4, 0103000020E6100000...).
For an operator that is enough. For an API returning a message to a user who just drew a shape on a map, it is useless. The fix is to check in the application before the insert, using the same predicate, and to turn the reason into something actionable:
from sqlalchemy import func, select
def validate_geometry(session, wkt: str, srid: int = 4326) -> None:
"""Raise with a human-readable reason before the constraint would fire."""
geom = func.ST_GeomFromText(wkt, srid)
valid, reason = session.execute(
select(func.ST_IsValid(geom), func.ST_IsValidReason(geom))
).one()
if not valid:
raise ValueError(f"the shape you drew is not usable: {reason}")This is deliberate duplication. The application check produces the good error message; the constraint guarantees the invariant regardless of which client wrote the row — a migration script, a psql session, a colleague’s notebook. Removing either one is a mistake: the application check alone is a convention, and the constraint alone is a bad user experience.
Configuration and Tuning Knobs
lock_timeout should be short — three to five seconds. It is not there to give the ALTER time to succeed; it is there to make failure cheap so the retry loop can back off and try again when the table is quieter.
For bulk-ingest tables where the per-row GEOS call is genuinely too expensive, keep the constraint NOT VALID permanently. It then documents the invariant and is enforced on ordinary writes, while a loader that uses COPY bypasses it — COPY does check constraints, so in that case validate in the loader instead and drop the constraint to a comment. Measure before assuming: on typical parcel polygons the check costs around forty microseconds, which is invisible next to the WAL write.
If the table is partitioned, add the constraint on each partition rather than on the parent. A constraint on the parent is inherited and validated per partition anyway, but adding it partition by partition lets you spread the validation scan across several maintenance windows.
Verification Steps
-- the constraint exists and history has been checked
SELECT conname, convalidated
FROM pg_constraint
WHERE conrelid = 'parcels'::regclass
AND contype = 'c';
-- conname | convalidated
-- ----------------------+--------------
-- parcels_geom_valid | t
-- it actually rejects bad geometry
INSERT INTO parcels (geom)
VALUES (ST_GeomFromText(
'POLYGON((0 0, 1 1, 1 0, 0 1, 0 0))', 4326));
-- ERROR: new row violates check constraint "parcels_geom_valid"Running that insert deliberately, in a transaction you roll back, is worth doing once. A constraint that exists but does not fire — because the expression was written against the wrong column, say — looks identical in the catalogue to one that works.
Gotchas Checklist
- Validation fails on the first bad row, after scanning everything before it. Always run the count query first; discovering a straggler forty minutes into validation wastes the whole scan.
convalidatedis the column that matters, notconname. A constraint addedNOT VALIDand never validated looks present in most tooling but guarantees nothing about existing rows.COPYdoes enforce check constraints. The common belief that it bypasses them is wrong — it bypasses rules and some trigger types, not constraints. If a bulk load slows down after adding this constraint, that is why.- Partition parents and children each need attention. Adding the constraint only to the parent still validates each child, so the lock behaviour is per-partition regardless; be explicit about it rather than surprised by it.
- An
ST_IsValidconstraint does not imply simple geometry. A valid polygon can still be aMULTIPOLYGON EMPTY, and a valid linestring can still self-intersect — validity is a polygonal concept. AddNOT ST_IsEmpty(geom)separately if emptiness matters.
Related Topics
- Geometry Validity and Repair — parent topic: the whole detect, repair and prevent cycle
- Detecting Invalid Geometries at Scale — proving the table is clean before validating
- ST_MakeValid Strategies for Polygon Repair — clearing the violations this constraint would reject
- Spatial Schema Migrations & Evolution — the lock discipline this page borrows