Finding Gaps and Overlaps in Polygon Coverages with PostGIS
A coverage is a set of polygons that should tile an area exactly: no two overlapping, nothing uncovered. Real coverages fail both conditions constantly, mostly by tiny amounts that are digitising noise and occasionally by amounts that represent a genuine disagreement about where a boundary is. The job of a validator is to separate those two populations reliably, and that requires different techniques for each defect — an indexed self-join for overlaps, a union-and-extract-rings pass for gaps — plus a tolerance model that reflects how the data was captured. This guide implements both in PostGIS, applying the rules described in Understanding OGC Topology Rules.
Prerequisites
- PostGIS 3.2+ (3.4+ preferred for the coverage functions) on PostgreSQL 14+.
- A single CRS across the coverage, projected, in metres. Mixed CRS produces overlaps that are entirely artefacts of the mismatch.
- Valid geometry. Topology predicates on invalid input raise
TopologicalErroror return nonsense; run the validity pass first. - A GiST index on the geometry column, and current statistics:
CREATE INDEX ... USING GIST (geom); ANALYZE parcels; - The positional accuracy of the source survey, which sets every tolerance below.
Step-by-Step Procedure
Step 1 — Prepare the coverage
-- Preconditions, checked rather than assumed.
SELECT
count(*) AS features,
count(DISTINCT ST_SRID(geom)) AS distinct_srids,
count(*) FILTER (WHERE NOT ST_IsValid(geom)) AS invalid,
count(*) FILTER (WHERE ST_IsEmpty(geom)) AS empty,
count(*) FILTER (WHERE ST_GeometryType(geom)
NOT IN ('ST_Polygon', 'ST_MultiPolygon')) AS wrong_type
FROM parcels;
-- All three counts must be zero and distinct_srids must be 1 before proceeding.
CREATE INDEX IF NOT EXISTS parcels_geom_gist ON parcels USING GIST (geom);
ANALYZE parcels;
Verification: a non-zero invalid count invalidates everything downstream — an invalid polygon can report an overlap with a neighbour it does not touch. Fix validity first, using the triage in Diagnosing Invalid Geometry with ST_IsValidDetail.
Step 2 — Detect overlaps with an indexed self-join
CREATE TABLE qa.coverage_overlaps AS
SELECT
a.parcel_id AS a_id,
b.parcel_id AS b_id,
ST_Intersection(a.geom, b.geom) AS overlap_geom,
ST_Area(ST_Intersection(a.geom, b.geom)) AS overlap_m2,
-- Width proxy: 4 * area / perimeter approximates the mean width of a thin shape.
CASE WHEN ST_Perimeter(ST_Intersection(a.geom, b.geom)) > 0
THEN 4 * ST_Area(ST_Intersection(a.geom, b.geom))
/ ST_Perimeter(ST_Intersection(a.geom, b.geom))
ELSE 0 END AS mean_width_m
FROM parcels a
JOIN parcels b
ON a.parcel_id < b.parcel_id -- each unordered pair once
AND a.geom && b.geom -- bounding-box prefilter, uses the GiST index
WHERE ST_Intersects(a.geom, b.geom)
AND NOT ST_Touches(a.geom, b.geom)
AND ST_Area(ST_Intersection(a.geom, b.geom)) > 0;
CREATE INDEX ON qa.coverage_overlaps USING GIST (overlap_geom);
Verification: run EXPLAIN (ANALYZE, BUFFERS) and confirm the plan uses an index scan on the && operator. Without it the join is a nested loop over the full table, and on 200,000 parcels that is forty billion comparisons — the case discussed in the parent topic’s performance section.
The a.parcel_id < b.parcel_id condition and the NOT ST_Touches filter are both essential: the first stops every overlap being reported twice, the second stops every correctly shared boundary being reported as a defect.
Step 3 — Detect gaps by extracting the union’s interior rings
-- The union of a correct coverage has no interior rings. Every hole is a gap.
CREATE TABLE qa.coverage_gaps AS
WITH merged AS (
SELECT ST_Union(geom) AS geom FROM parcels
),
parts AS (
SELECT (ST_Dump(geom)).geom AS poly FROM merged
),
rings AS (
SELECT ST_MakePolygon(ST_InteriorRingN(poly, n)) AS gap_geom
FROM parts,
LATERAL generate_series(1, ST_NumInteriorRings(poly)) AS n
)
SELECT
row_number() OVER () AS gap_id,
gap_geom,
ST_Area(gap_geom) AS gap_m2,
ST_Perimeter(gap_geom) AS perimeter_m,
CASE WHEN ST_Perimeter(gap_geom) > 0
THEN 4 * ST_Area(gap_geom) / ST_Perimeter(gap_geom)
ELSE 0 END AS mean_width_m
FROM rings;
CREATE INDEX ON qa.coverage_gaps USING GIST (gap_geom);
Verification: on a coverage you believe to be clean this should return only genuine enclosed areas — a lake, a road reserve, an unregistered plot. Hundreds of hair-thin rings means edge-match noise, which Step 4 filters.
Step 4 — Filter by tolerance derived from the survey
-- Tolerance comes from the survey, not from taste. Photogrammetry at ±0.3 m here.
CREATE OR REPLACE VIEW qa.coverage_defects AS
SELECT 'overlap' AS defect_type, a_id AS feature_a, b_id AS feature_b,
overlap_m2 AS area_m2, mean_width_m, overlap_geom AS geom,
CASE WHEN mean_width_m < 0.30 THEN 'noise'
WHEN mean_width_m < 1.00 THEN 'sliver'
ELSE 'material' END AS class
FROM qa.coverage_overlaps
UNION ALL
SELECT 'gap', NULL, NULL, gap_m2, mean_width_m, gap_geom,
CASE WHEN mean_width_m < 0.30 THEN 'noise'
WHEN mean_width_m < 1.00 THEN 'sliver'
ELSE 'material' END
FROM qa.coverage_gaps;
-- What the reviewer actually gets.
SELECT defect_type, class, count(*), round(sum(area_m2)::numeric, 1) AS total_m2
FROM qa.coverage_defects
GROUP BY defect_type, class
ORDER BY defect_type, class;
Verification: the noise class should dominate by count and be negligible by area. If material defects dominate by count, the coverage has a real problem; if noise dominates by area, the tolerance is set too high and genuine defects are being classified away.
Step 5 — Give material defects their context
-- Which parcels surround each material gap? That is who has to agree on the fix.
SELECT g.gap_id,
round(g.gap_m2::numeric, 2) AS gap_m2,
round(g.mean_width_m::numeric, 2) AS width_m,
array_agg(p.parcel_id ORDER BY p.parcel_id) AS bounding_parcels
FROM qa.coverage_gaps g
JOIN parcels p ON ST_Intersects(ST_Buffer(g.gap_geom, 0.05), p.geom)
WHERE g.mean_width_m >= 1.0
GROUP BY g.gap_id, g.gap_m2, g.mean_width_m
ORDER BY g.gap_m2 DESC;
Verification: a material gap bounded by two parcels is usually an edge-match failure between two surveys. One bounded by five or more is often a genuinely unregistered parcel — an entirely different finding, and one the registry will care about more than the pipeline does.
Interpreting Results
| Pattern | Interpretation | Action |
|---|---|---|
| Thousands of sub-centimetre overlaps along shared edges | Digitising noise | Filter as noise; consider a snap pass |
| Overlaps of a constant width along one boundary run | Two surveys with a systematic offset | Escalate; snapping would move a correct boundary |
| One large gap bounded by many parcels | Probably an unregistered parcel or public land | Registry question, not a data repair |
| Gaps forming a continuous network | Road reserves excluded from the coverage by design | Expected — exclude the road corridor from the rule |
| Overlaps concentrated in one map sheet | A single digitising session | Investigate the batch |
| Gap count falls after a validity repair | Invalid geometry was distorting the union | Correct behaviour; re-baseline the counts |
The width classification does the real work. Area alone is misleading: a 40-metre-long gap 5 centimetres wide has an area of 2 square metres, the same as a 1.4-metre square hole, and the two are entirely different findings. Mean width — four times area over perimeter for a thin shape — separates them cleanly, and it is the same measure used for sliver detection in Detecting Sliver Polygons in Parcel Data.
Gotchas & Edge Cases
ST_Union on a large coverage is memory-hungry. It builds the whole result in memory. Beyond a few hundred thousand polygons, union by tile with a small overlap margin and deduplicate the gaps found at tile boundaries — or use ST_CoverageInvalidEdges and the related coverage functions in PostGIS 3.4+, which are purpose-built and far faster.
The coverage boundary is not a gap. Interior rings of the union are holes; the outer boundary is the coverage extent. The ST_InteriorRingN approach handles this naturally, but a naive difference against a study-area polygon will report the entire margin as a gap.
Deliberate exclusions must be excluded from the rule. Road reserves, water bodies and public land are often outside the parcel coverage by design. Subtract them before the union, or every one becomes a large material gap and the report becomes noise.
&& compares bounding boxes, not geometries. It is a prefilter, never a predicate. Omitting the exact ST_Intersects test after it reports every pair whose boxes touch, which for elongated features is most of them.
Touching is not overlapping. ST_Intersects is true for correctly shared boundaries. NOT ST_Touches removes them, but a pair that both touches along one edge and overlaps elsewhere is ST_Touches-false and correctly retained — the combination handles the subtle cases.
Floating-point coordinates make exact touching rare. Two boundaries digitised separately almost never share coordinates exactly. That is why the tolerance model exists, and why a rule demanding exact adjacency fails on real data.
When to Escalate
- Systematic offsets between adjacent survey batches must not be snapped away — one of the two boundaries is correct and snapping picks arbitrarily. Escalate with the affected extent and both batch identifiers.
- Material overlaps between parcels are ownership questions. They belong with the registrar under the severity model in Classifying Topology Errors by Severity, not with the pipeline.
- A union that will not complete means the coverage is too large for a single pass; that is an engineering escalation to tiling rather than a data finding.
- Gap counts that move without data changes point at tolerance or validity changes. Confirm the rule-set version before investigating the data.
Frequently Asked Questions
Why does ST_Union find gaps that a self-join cannot?
A self-join compares pairs, and a gap is not a property of any pair — it is a hole in the collective coverage, often bounded by three or more polygons. Unioning the whole coverage and taking the interior rings of the result finds those holes directly, regardless of how many features surround them.
How small a gap should be ignored?
Anything narrower than the positional accuracy of the source survey. If boundaries were digitised to ±0.5 m, a gap 0.2 m wide is measurement noise and reporting it wastes reviewer time. Filter on width — the ratio of area to perimeter — rather than on area alone, because a long thin gap can have a large area and still be noise.
Is ST_Union on a large coverage practical?
Up to a few hundred thousand polygons on a well-resourced server, yes, though it is memory-hungry. Beyond that, union by tile with an overlap margin and reconcile the tile boundaries, or use the coverage functions in PostGIS 3.4+ which are designed for exactly this and are substantially faster.
Should overlaps and gaps be repaired automatically?
Narrow ones caused by edge-match noise, yes — snapping to a shared boundary is deterministic. Wide ones, no: a two-metre overlap between parcels is a boundary dispute and the pipeline has no basis for deciding which owner loses land. The width threshold separating the two is the same one used to filter noise, and it needs to be a written policy.
Related
- Understanding OGC Topology Rules — the predicate model behind these queries
- Detecting Sliver Polygons in Parcel Data — the same width measure applied to features rather than defects
- Snapping Vertices to Tolerance in PostGIS — the repair for noise-class defects
Back to Understanding OGC Topology Rules