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.

Gap, overlap and sliver — same picture, different measurements Three magnified drawings. The gap panel shows two parcels separated by a narrow unclaimed strip; it is detected by subtracting the union of parcels from the coverage boundary, and the owner is whoever digitised the boundary. The overlap panel shows two parcels whose edges cross so a lens-shaped region belongs to both; it is detected by a pairwise intersection filtered by a minimum area, and the owner is the producer because it usually indicates duplicate capture. The sliver panel shows a long, extremely thin polygon left behind by an overlay; area alone cannot distinguish it from a genuine narrow feature, so it is detected by the ratio of area to perimeter squared, and the owner is the pipeline that generated it. Gap unclaimed strip between parcels detect: boundary − union(parcels) owner: whoever digitised the edge threshold: min gap area Overlap claimed twice — both parcels own it detect: pairwise ST_Intersection owner: producer — duplicate capture threshold: min overlap area Sliver real polygon, near-zero area detect: area / perimeter² ratio owner: the pipeline that made it threshold: shape compactness

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 4πA/P24\pi A / P^2 approaches zero for a sliver and stays well above it for any real feature, however small. Using compactness rather than area is the difference between a filter that removes artefacts and one that quietly deletes data.

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
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 nn parcels considers n(n1)/2n(n-1)/2 pairs. At ten thousand parcels that is fifty million candidate pairs, and evaluating 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 nn rather than quadratic. The index only engages if the join predicate uses the && 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”.

Candidate pairs with and without a usable box predicate Two plans compared. The naive plan evaluates every pair of parcels: the count grows as n times n minus one over two, shown reaching fifty million candidate pairs at ten thousand parcels, and the exact intersection is computed for all of them. The indexed plan uses the bounding-box operator so GiST returns only spatial neighbours, roughly eight candidates per parcel, giving about forty thousand pairs at the same scale; the exact intersection then runs only on that small candidate set. A warning box records that wrapping the join predicate in an area calculation removes the box predicate the planner needs and silently returns to the quadratic plan. No usable box predicate every pair considered — n(n−1)/2 10 000 parcels → 50 000 000 pairs exact ST_Intersection on all of them Runtime: superlinear — times out Box predicate reaches the planner GiST returns spatial neighbours only 10 000 parcels → ~40 000 pairs exact ST_Intersection on survivors only Runtime: near-linear — seconds The trap: wrapping the predicate in an area calculation hides the bounding box from the planner. Filter on the relationship first, measure the area second — never the other way round, or the plan silently reverts to the left-hand column.

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.

Detection, gate, constraint — three strengths of the same rule Three stages increasing in strength. Stage one, the nightly detection query, finds violations after they exist, works on historical data, and adds no cost to writes, but the bad data is already stored. Stage two, the pre-merge test against a fixture subset, blocks code changes that would create overlaps and gives fast feedback, but a direct database write bypasses it entirely. Stage three, an exclusion constraint using GiST on the geometry with the overlap operator, rejects the offending insert at write time so the invariant cannot be violated, at the cost of index maintenance on every write and a requirement that all existing violations be cleared before the constraint can be created. A footer notes that the three coexist rather than replace one another. 1 · Nightly detection + finds violations already stored + works on historical data + zero write-path cost − the damage is already done 2 · Pre-merge gate + blocks code that would break it + fast feedback on a subset + failure names the change − a direct write bypasses it 3 · Exclusion constraint + the insert itself is rejected + invariant cannot be violated − index maintained on every write − must clear violations first The three coexist — they do not replace one another. The constraint protects the invariant going forward; the gate keeps the failure attributable to a change; the nightly query is the only one that can see what was already wrong before any of it was switched on — and the only one that keeps working during a bulk load with constraints deferred.

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

  1. No area floor. A bare ST_Overlaps self-join reports every snap sliver as a defect; always gate on intersection area.
  2. Envelope gaps counted as real. The difference against ST_Envelope includes the space between the coverage’s true boundary and its bounding box; restrict to interior polygons with ST_Touches.
  3. Mixed SRIDs. Running the area floor in degrees (EPSG:4326) makes 0.01 a ~1 km threshold; use a metric SRID.
  4. Unindexed self-join. Without a GiST index the ST_Intersects self-join scans O(n²); create the index first, per R-tree vs GiST.
  5. 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.