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.
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;
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.
Related
- Geometry validity and topology checks — the parent topic covering per-geometry validity.
- Monitoring point-in-polygon match rates — the downstream symptom these defects produce.
- Detecting reassignment after a boundary refresh — the check to run alongside this one when a coverage changes.