Finding Slivers and Gaps in Adjacent Polygon Coverage

Every polygon in an administrative coverage can be individually valid while the coverage as a whole is broken. Two neighbouring districts digitised from slightly different source scales leave a hairline gap along their shared edge; a re-projection nudges vertices apart and creates a sliver of overlap; a boundary correction applied to one polygon and not its neighbour opens a wedge. ST_IsValid returns true for every one of them, because validity is a property of a single geometry and these are defects between geometries. Downstream, the gaps become unmatched points and the overlaps become double-counted ones — the two failure modes that dominate point-in-polygon enrichment. This guide covers how to detect both, how to filter the results down to the ones that matter, and how to alert without drowning in cartographic noise. It belongs to geometry validity and topology checks under spatial data freshness and quality metrics.

Gap and sliver defects along a shared boundary, both invisible to per-geometry validity Three panels show the same pair of adjacent polygons. The first is correct, with the shared edge coincident. The second shows a gap: the two polygons pull apart slightly, leaving a thin unclaimed strip between them where points will fail to match any polygon. The third shows a sliver overlap: the polygons cross slightly, leaving a thin doubly-claimed strip where points will match two polygons. A note beneath states that all six polygons are individually valid. All six polygons pass ST_IsValid; two of the three pairings are broken coincident every point matches exactly one gap unclaimed strip → unmatched points sliver overlap doubly-claimed strip → duplicated points Both defects are sub-metre in the real world and unconditionally visible to a spatial join.

Problem framing: measure the defect, not its existence

Any real coverage contains thousands of tiny gaps and overlaps. Reporting all of them produces a list nobody reads. The useful check reports the ones that will affect an answer, and three properties separate those from cartographic noise.

Area. A sliver of one square centimetre affects nothing. One of two hundred square metres along a city block will collect addresses. Filter by area against the layer’s own scale — a sensible floor is the area of a square whose side is the coverage’s positional tolerance.

Length. A short gap between two vertices is a digitising artefact; a gap running the whole length of a shared edge is a boundary that was moved on one side only. Length distinguishes a defect from a wrinkle even when areas are similar.

Occupancy. The decisive test: does anything actually fall in the gap or the overlap? A two-hundred-square-metre sliver in open countryside is irrelevant, and the same sliver across a residential street affects forty addresses. Where you have a point layer to test against, occupancy converts a topology report into a prioritised work list.

Implementation: gaps by difference, overlaps by intersection

Both defects come from set operations against the coverage’s own union, and both are cheap enough to run nightly on a national layer if the candidate pairs are restricted by the spatial index.

-- Overlaps: intersect every pair of neighbours once.
-- The a.district_id < b.district_id clause halves the work and removes
-- self-pairs; ST_Intersects is index-assisted, so only true neighbours are
-- ever passed to the expensive ST_Intersection.
CREATE TEMP TABLE overlap AS
SELECT a.district_id                     AS left_id,
       b.district_id                     AS right_id,
       ST_Intersection(a.geom, b.geom)   AS geom
FROM curated.districts a
JOIN curated.districts b
  ON a.district_id < b.district_id
 AND ST_Intersects(a.geom, b.geom)
WHERE NOT ST_Touches(a.geom, b.geom);    -- shared edges are correct, not overlaps

SELECT left_id, right_id,
       ST_Area(geom::geography)                       AS area_m2,
       ST_Perimeter(geom::geography)                  AS perimeter_m,
       -- Thinness: a sliver has a large perimeter for its area.
       ST_Perimeter(geom::geography) ^ 2
         / NULLIF(ST_Area(geom::geography), 0)        AS thinness
FROM overlap
WHERE ST_Area(geom::geography) > 1.0                  -- ignore sub-metre noise
ORDER BY area_m2 DESC;

Gaps are the complement: the difference between the coverage’s convex or declared extent and the union of its polygons, restricted to interior holes rather than the outer boundary.

-- Gaps: holes inside the union of the coverage.
-- ST_Union of a national layer is expensive, so restrict to a working region
-- or run it per parent authority rather than nationally in one statement.
WITH merged AS (
  SELECT ST_Union(geom) AS geom
  FROM curated.districts
  WHERE parent_authority = :authority
),
holes AS (
  SELECT (ST_Dump(ST_MakePolygon(ST_ExteriorRing((ST_Dump(geom)).geom)))).geom AS shell,
         geom
  FROM merged
)
SELECT ST_Area(hole::geography)      AS area_m2,
       ST_Perimeter(hole::geography) AS perimeter_m,
       ST_AsText(ST_Centroid(hole))  AS where_
FROM (
  SELECT (ST_Dump(ST_Difference(shell, geom))).geom AS hole FROM holes
) g
WHERE ST_Area(hole::geography) > 1.0
ORDER BY 1 DESC;

Occupancy turns the list into priorities, and it is one join away once you have the defect geometries.

-- How many addresses fall in each gap? This is the ordering that matters.
SELECT g.area_m2,
       COUNT(a.address_id) AS affected_addresses
FROM gap_geometries g
LEFT JOIN curated.addresses a ON ST_Intersects(g.geom, a.geom)
GROUP BY g.area_m2
ORDER BY affected_addresses DESC, g.area_m2 DESC;
Filtering thousands of raw topology defects down to the few that affect answers A funnel narrows in four stages. The raw set contains about fourteen thousand gap and overlap geometries detected across a national coverage. Filtering by an area floor of one square metre reduces it to about nine hundred. Adding a thinness threshold that keeps only elongated slivers reduces it to about three hundred. Requiring at least one affected address reduces it to twenty-two, labelled as the actionable set. A note states that alerting on the raw count would be permanently red. Filter to the defects that change an answer raw gaps + overlaps 14 210 area > 1 m² 903 thinness > 40 (elongated slivers) 311 affects ≥ 1 address 22 the work list Occupied defects by the boundary they sit on Ten shared boundaries are ranked by the number of addresses falling into gaps or slivers along them. One boundary accounts for more than half the total, and it is the edge between two authorities that revised their boundaries at different times. The remaining boundaries contribute small counts. The note states that fixing the single worst edge resolves most of the downstream match-rate impact. One edge accounts for most of the impact — fix that first authority A / authority B edge 612 addresses authority B / authority C edge 128 authority C / coastal boundary 81 authority A / authority D edge 44 six remaining edges combined 56

Alerting

Alert on the filtered, occupied count rather than the raw one, and compare against the layer’s own baseline because every coverage has a resident population of small defects.

- alert: CoverageTopologyDefects
  expr: |
    gis_spatial_occupied_topology_defects
      > 1.5 * avg_over_time(gis_spatial_occupied_topology_defects[14d] offset 1d)
  for: 1h
  labels: { severity: warning, data_domain: spatial }
  annotations:
    summary: >-
      {{ $value }} occupied gaps/slivers on {{ $labels.layer }} — up against baseline

- alert: CoverageTopologyDefectSpike
  expr: gis_spatial_occupied_topology_defects > 200
  for: 30m
  labels: { severity: critical, data_domain: spatial }

The absolute threshold on the second rule catches the case the baseline comparison cannot: a wholesale re-generalisation that opens defects everywhere at once, which moves the baseline as fast as the value.

Verification

Manufacture each defect class. Shift one polygon by half a metre and confirm a gap of the expected area is reported along the full shared edge. Extend one polygon by half a metre and confirm a sliver of the same area appears with a high thinness score. Then confirm the occupancy join attributes the correct address count to each.

Then confirm the filters are not hiding real defects. Drop the area floor to zero temporarily and check that the additional results really are noise — sub-centimetre artefacts with no occupancy. A floor that is silently excluding metre-scale defects because the units were wrong (degrees rather than metres) is a common and quiet failure; casting to geography, as the queries above do, avoids it.

Gotchas

Treating ST_Touches pairs as overlaps. Correctly adjacent polygons share an edge and intersect in a line. Excluding touching pairs is what keeps the overlap query from returning every neighbour in the coverage.

Running ST_Union nationally in one statement. Memory-expensive and slow. Partition by a parent authority or a grid and union within each part.

Area computed in degrees. On unprojected data ST_Area returns square degrees, and a threshold of 1.0 then excludes essentially everything. Cast to geography or work in a projected system.

Ignoring the outer boundary. The difference between an extent and the union includes everything outside the coverage. Restrict to interior holes, or every run reports one enormous “gap” that is simply the rest of the world.

Alerting on the raw defect count. Permanently red, permanently ignored. Filter by area, thinness and occupancy first.

FAQ

What thinness threshold identifies a sliver?

The squared-perimeter-over-area ratio is around 16 for a square and rises steeply as a shape elongates; a threshold of 40 captures clearly elongated shapes without catching ordinary small polygons. Calibrate it once against a sample of known defects in your own data, since digitising style affects the distribution.

Should slivers be repaired automatically?

Snapping neighbouring boundaries to a common tolerance is a reasonable automated fix for slivers of a few square metres, and a poor idea for anything larger, since a large discrepancy usually means one side was genuinely revised. Automate below a threshold you are comfortable defending, and route the rest to review.

How does this relate to point-in-polygon match rates?

Directly and causally: gaps produce coverage_gap unmatched points and slivers produce ambiguous double matches, which are exactly the two anomalies counted in monitoring point-in-polygon match rates. A match-rate drop with a coverage_gap majority should send you straight to this check.

Is this worth running on every coverage?

Run it on any coverage used as the right-hand side of a spatial join, which is where the defects cause harm. A display-only cartographic layer with the same defects costs nothing but a slightly untidy map.

What if the coverage is genuinely allowed to have holes?

Some coverages legitimately exclude areas — water bodies removed from a land-parcel layer, military land excluded from a cadastral set. Model those exclusions explicitly as a mask geometry and subtract it before looking for holes, rather than accepting a permanently non-zero defect count. An explicit mask also documents the exclusion for downstream consumers, who otherwise discover it as an unexplained cluster of unmatched points.

How often should it run?

After every refresh of the coverage, and otherwise weekly. The defects do not appear spontaneously — they arrive with a boundary update, a reprojection or a generalisation — so tying the check to the refresh catches them at the moment they can still be rejected.

Where a defect list is reviewed by a cartographer rather than an engineer, exporting the filtered geometries as a small vector file with their occupancy counts attached is far more useful than a table of identifiers. The reviewer can open it over the source imagery and decide in seconds whether an edge was digitised badly or genuinely moved, which is a judgement no automated threshold makes well.