Enforcing No-Gaps/No-Overlaps with PostGIS
A planar coverage — parcels, administrative boundaries, land-use zones — must tile its area with no gaps between neighbours and no overlaps, and PostGIS is the right place to enforce that when the data lives in the database and the rule must also gate ingestion. This guide sits beneath topology rule enforcement and shows how to detect overlaps with a self-join, detect gaps against the coverage boundary, tolerate the sub-centimetre slivers that spatial operations inevitably produce, and wire the whole thing as both a test and an ingestion constraint. The reason to push this to the database is scale and reuse: the same SQL that a nightly gate runs against a million rows can run as a trigger so invalid geometry never persists.
Why gaps and overlaps need explicit detection
Neither gaps nor overlaps show up in a validity check — each individual polygon can be perfectly valid while the coverage they form is broken. Two adjacent parcels can overlap by a metre because their shared boundary was digitised twice, or leave a gap because a boundary was snapped inconsistently. These are relationship defects across features, so detecting them needs a spatial join (for overlaps) or a set operation against the whole coverage (for gaps), not a per-row predicate. Whether to run this in PostGIS or in-process Shapely is exactly the placement decision in Shapely vs PostGIS for topology — the database wins here because the rule must also constrain writes.
Three defects that look identical in a viewer
At any realistic zoom level a gap, an overlap, and a sliver all render as “the parcels look fine”. They are separated only by measurement, and each needs a different threshold and a different owner, so a check that lumps them together produces reports nobody can act on.
The third measurement is the one most often got wrong. Filtering slivers by area alone deletes genuine narrow features — a drainage reserve, a right of way, a coastal strip — because a legitimately thin parcel and a clipping artefact can have identical areas. What separates them is shape: the Polsby-Popper compactness ratio
Detection reference
| Defect | Detection | Tolerance strategy |
|---|---|---|
| Overlap | ST_Overlaps self-join on a.id < b.id |
Area floor on ST_Area(ST_Intersection) |
| Sliver overlap | intersection area below floor | Ignore below e.g. 0.01 m² |
| Gap | difference of coverage envelope and ST_Union |
Area floor on gap polygons |
| Boundary mismatch | ST_Touches should hold for neighbours |
Snap tolerance via ST_SnapToGrid |
Step-by-step implementation
The pattern targets PostGIS 3.x on PostgreSQL 14+, with a metric SRID so tolerances are in metres.
Step 1 — Detect overlaps with an area floor
A raw ST_Overlaps self-join flags harmless slivers as failures, so measure the intersection area and ignore anything below a floor.
-- Overlaps larger than 0.01 m² are real defects; smaller are slivers
SELECT a.id AS a_id, b.id AS b_id,
ST_Area(ST_Intersection(a.geom, b.geom)) AS overlap_m2
FROM parcels a
JOIN parcels b ON a.id < b.id
WHERE ST_Intersects(a.geom, b.geom)
AND ST_Area(ST_Intersection(a.geom, b.geom)) > 0.01;
Step 2 — Detect gaps against the coverage
Union the coverage, subtract it from its own convex or bounding envelope, and any interior residual is a gap. Filter by area to drop edge slivers.
-- Interior gaps: holes in the unioned coverage
SELECT (dump).path, ST_Area((dump).geom) AS gap_m2
FROM (
SELECT ST_Dump(ST_Difference(ST_Envelope(ST_Union(geom)), ST_Union(geom))) AS dump
FROM parcels
) g
WHERE ST_Area((dump).geom) > 0.01
AND NOT ST_Touches((dump).geom, ST_Boundary(ST_Envelope(ST_Union(geom)))); -- interior only
Step 3 — Drive both from the test suite
import psycopg2
def count(cur, sql):
cur.execute(sql); return cur.fetchall()
def test_coverage_has_no_overlaps_or_gaps(dsn):
with psycopg2.connect(dsn) as conn, conn.cursor() as cur:
overlaps = count(cur, OVERLAP_SQL)
gaps = count(cur, GAP_SQL)
assert not overlaps, f"{len(overlaps)} overlaps"
assert not gaps, f"{len(gaps)} gaps"
Step 4 — Promote the rule to an ingestion constraint
The same overlap check becomes a trigger so bad geometry cannot be written, not merely detected after the fact.
CREATE OR REPLACE FUNCTION reject_overlap() RETURNS trigger AS $$
BEGIN
IF EXISTS (SELECT 1 FROM parcels p
WHERE p.id <> NEW.id AND ST_Overlaps(p.geom, NEW.geom)
AND ST_Area(ST_Intersection(p.geom, NEW.geom)) > 0.01) THEN
RAISE EXCEPTION 'parcel % overlaps an existing parcel', NEW.id;
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
Making the self-join affordable
The naive overlap query is a self-join with an inequality on the identifier, and on a coverage of any size it is the slowest thing in the suite. Understanding why turns a query that times out into one that runs in seconds.
A self-join over ST_Intersects on each is hopeless. The GiST index reduces it to the pairs whose bounding boxes actually overlap, which for a planar coverage is roughly a constant number of neighbours per parcel — so the work becomes linear in && bounding-box operator, either explicitly or implicitly through a function the planner can rewrite. A join written on ST_Intersects alone will use the index in modern PostGIS; one written on ST_Area(ST_Intersection(a, b)) > 0 will not, because the planner cannot recover a box predicate from it, and that single difference is the usual cause of a topology check that “hangs”.
Two further refinements make the query production-ready. Add a.id < b.id rather than a.id <> b.id so each pair is examined once instead of twice — a free halving. And apply the area floor after the intersection has been computed on the surviving candidates, as a HAVING-style filter, rather than folding it into the join condition where it would defeat the index. The resulting shape is: cheap box filter, exact predicate on survivors, measurement last.
For gap detection the same principle applies in a different form. Do not difference the boundary against each parcel in turn; build the union once, difference once, then explode the result. The union is a single expensive operation over the whole layer, which is far cheaper than repeating a difference per feature, and the exploded parts come out already separated for measurement.
Verification pattern
Seed two deliberately overlapping parcels and assert the detector finds exactly one overlap pair.
INSERT INTO parcels(id, geom) VALUES
(1, ST_GeomFromText('POLYGON((0 0,10 0,10 10,0 10,0 0))', 25832)),
(2, ST_GeomFromText('POLYGON((9 0,19 0,19 10,9 10,9 0))', 25832)); -- overlaps parcel 1
-- The overlap query must return exactly one row (a_id=1, b_id=2).
From a nightly report to an ingestion constraint
A detection query tells you the coverage is already broken. A constraint stops it breaking. The progression between them is the same three-step promotion that any topology rule should follow, and each step buys a different property.
The ordering is not optional. An exclusion constraint cannot be created while violations exist, so the nightly query and a remediation pass always come first; teams that attempt the constraint on day one discover this during a migration window. Note too that the constraint is a pairwise guarantee — it makes overlaps impossible but says nothing about gaps, because a gap is the absence of data and no per-row constraint can require a row that was never inserted. Gap detection therefore stays a query forever, which is a good reason to keep the nightly job even after the constraint lands.
Failure modes and edge cases
- No area floor. A bare
ST_Overlapsself-join reports every snap sliver as a defect; always gate on intersection area. - Envelope gaps counted as real. The difference against
ST_Envelopeincludes the space between the coverage’s true boundary and its bounding box; restrict to interior polygons withST_Touches. - Mixed SRIDs. Running the area floor in degrees (EPSG:4326) makes
0.01a ~1 km threshold; use a metric SRID. - Unindexed self-join. Without a GiST index the
ST_Intersectsself-join scans O(n²); create the index first, per R-tree vs GiST. - Trigger performance on bulk load. A per-row trigger on a million-row COPY is slow; validate in batch after load for bulk ingestion, keeping the trigger for incremental writes.
Conclusion
Enforcing no-gaps/no-overlaps in PostGIS is an area-floored ST_Overlaps self-join plus a coverage-difference gap check, driven from the suite and promotable to an ingestion trigger so bad geometry never persists. Because these are coverage-level relationship defects, they need set operations, not per-row validity — and the database is where they scale and double as a write constraint. For the broader topology context, return to topology rule enforcement.