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.
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;
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).
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.