Problem Statement
A national cadastre table holds forty-one million polygons and nobody knows how many are invalid, because the only tool anyone has tried — SELECT count(*) FROM parcels WHERE NOT ST_IsValid(geom) — has never finished. This page, part of geometry validity and repair, builds an audit that does finish: incremental, resumable, safe to run during business hours, and cheap enough to keep permanently current afterwards.
Why the Naive Approach Fails
There is a second, subtler problem with the one-shot query: it gives you a count and nothing else. A count cannot be acted on. What the repair step needs is a per-row reason, because a bowtie and a hole that escaped its shell call for different responses — one is a mechanical repair, the other is a data-quality incident.
Production-Ready Implementation
The audit table is keyed by the parcel id and carries the reason, the failure location and a timestamp:
CREATE TABLE parcel_validity (
id bigint PRIMARY KEY REFERENCES parcels(id) ON DELETE CASCADE,
is_valid boolean NOT NULL,
reason text,
location geometry(Point, 4326),
vertices integer NOT NULL,
checked_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX parcel_validity_reason_idx ON parcel_validity (reason)
WHERE NOT is_valid;Storing valid rows as well as invalid ones costs a little disk and buys the ability to answer “when was this row last checked?” — which is what makes the audit incremental rather than repeated.
The batch statement checks a keyset window and upserts the result:
WITH batch AS (
SELECT p.id, p.geom
FROM parcels p
LEFT JOIN parcel_validity v ON v.id = p.id
WHERE p.id > %(after)s
AND v.id IS NULL -- not yet audited
ORDER BY p.id
LIMIT %(size)s
),
checked AS (
SELECT b.id,
(d).valid AS is_valid,
(d).reason AS reason,
(d).location AS location,
ST_NPoints(b.geom) AS vertices
FROM batch b, LATERAL ST_IsValidDetail(b.geom) AS d
)
INSERT INTO parcel_validity (id, is_valid, reason, location, vertices)
SELECT id, is_valid, reason, location::geometry(Point, 4326), vertices
FROM checked
ON CONFLICT (id) DO UPDATE
SET is_valid = EXCLUDED.is_valid,
reason = EXCLUDED.reason,
location = EXCLUDED.location,
vertices = EXCLUDED.vertices,
checked_at = now()
RETURNING id;The Python runner is deliberately boring — its only jobs are advancing the watermark, committing, and staying out of the way:
import logging
import time
import psycopg
log = logging.getLogger("validity-audit")
def audit(dsn: str, batch_size: int = 2_000, pause: float = 0.05) -> dict:
"""Walk parcels in keyset order, recording validity. Resumable."""
stats = {"checked": 0, "invalid": 0, "batches": 0}
after = _resume_point(dsn)
with psycopg.connect(dsn, autocommit=False) as conn:
# keep this session out of the way of application traffic
with conn.cursor() as cur:
cur.execute("SET statement_timeout = '5min'")
cur.execute("SET application_name = 'validity-audit'")
conn.commit()
while True:
with conn.cursor() as cur:
cur.execute(BATCH_SQL, {"after": after, "size": batch_size})
ids = [r[0] for r in cur.fetchall()]
conn.commit()
if not ids:
log.info("audit complete: %s", stats)
return stats
after = max(ids)
stats["batches"] += 1
stats["checked"] += len(ids)
if stats["batches"] % 50 == 0:
log.info("audited %s rows, watermark id=%s",
stats["checked"], after)
time.sleep(pause) # leave headroom for live traffic
def _resume_point(dsn: str) -> int:
"""The highest id already audited — where the last run stopped."""
with psycopg.connect(dsn) as conn, conn.cursor() as cur:
cur.execute("SELECT coalesce(max(id), 0) FROM parcel_validity")
return cur.fetchone()[0]Because the resume point comes from the audit table itself rather than from a file or a variable, the job survives a crash, a deploy, and a database failover with no special handling.
The trigger that keeps the queue fed:
CREATE OR REPLACE FUNCTION parcels_invalidate_audit() RETURNS trigger AS $$
BEGIN
DELETE FROM parcel_validity WHERE id = NEW.id;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER parcels_geom_changed
AFTER UPDATE OF geom ON parcels
FOR EACH ROW
WHEN (OLD.geom IS DISTINCT FROM NEW.geom)
EXECUTE FUNCTION parcels_invalidate_audit();The WHEN clause matters: without it the trigger fires on every update to any column, and an update that only touches an owner name pointlessly re-queues an expensive validity check.
Configuration and Tuning Knobs
statement_timeout on the audit session should be generous but finite. A single batch of two thousand ordinary parcels finishes in under a second; a batch containing one pathological hundred-thousand-vertex coastline can take minutes. Five minutes catches genuine hangs without failing on legitimate outliers.
batch_size trades round-trips against transaction length. Two thousand rows is a good default for polygons averaging a few hundred vertices. If your table holds mostly small geometries, ten thousand is fine; if it holds coastlines, drop to two hundred.
The pause between batches exists solely to leave I/O headroom for application traffic. On a dedicated replica set it to zero. On a primary serving live queries, fifty milliseconds costs the audit about ten percent of its wall-clock time and makes it effectively invisible to everything else.
Verification Steps
Confirm the audit covered everything it should have:
-- every parcel has an audit row
SELECT count(*) AS unaudited
FROM parcels p
LEFT JOIN parcel_validity v ON v.id = p.id
WHERE v.id IS NULL;
-- the distribution that drives the repair plan
SELECT reason, count(*) AS n, round(avg(vertices)) AS avg_vertices
FROM parcel_validity
WHERE NOT is_valid
GROUP BY reason
ORDER BY n DESC;If unaudited is greater than zero after a completed run, rows were inserted while the audit was walking — expected, and the next pass picks them up. If it keeps growing, inserts are outpacing the audit and the batch size or schedule needs raising.
Gotchas Checklist
ST_IsValidDetailreturns a composite type. Access its fields as(d).reason, with the parentheses —d.reasonis a syntax error in this position and the mistake is easy to miss inside a CTE.- The location column needs an explicit type cast.
(d).locationis an untypedgeometry; assigning it to ageometry(Point, 4326)column requires the cast shown above or the insert fails on the type modifier. ON DELETE CASCADEon the audit table is not optional. Without it, deleting a parcel leaves an orphan audit row and the “every parcel has an audit row” check quietly starts comparing different sets.- Do not add the audit to the same transaction as the repair. Auditing and repairing in one transaction means a repair failure discards the audit result too, and you re-check the same forty million rows.
- Watch for
NaNcoordinates. They are not invalid by GEOS’s definition but break almost everything downstream. AddWHERE NOT ST_IsValid(geom) OR ST_XMin(geom) <> ST_XMin(geom)to catch them in the same pass.
Related Topics
- Geometry Validity and Repair — parent topic: the full detect, repair and prevent cycle
- ST_MakeValid Strategies for Polygon Repair — what to do with the rows this audit finds
- Enforcing Validity With Check Constraints — stopping the queue from refilling
- Backfilling and Zero-Downtime Migrations — the same batching discipline applied to writes