Detecting Sliver Polygons in Parcel Data
You are auditing a cadastral parcel layer and you suspect it is peppered with slivers — hairline gaps and overlaps left behind when neighbouring boundaries were digitised at different times or produced by an overlay. You need a repeatable rule that finds those slivers, ignores the legitimately narrow parcels that would trip a naive size filter, and hands off only the real problems to remediation. This page implements that rule with a thinness ratio, Polsby-Popper compactness, and area thresholds in both Shapely and PostGIS. It is part of Understanding OGC Topology Rules, where slivers are the classic symptom of a broken “boundary must not overlap” constraint.
Prerequisites
- A projected CRS in linear units. Area and perimeter are only meaningful in a projected coordinate reference system (CRS) — metres or feet, never degrees. Normalise every layer first; see Reprojecting Mixed-CRS Datasets with PyProj if your inputs arrive in different systems.
- Shapely 2.0+ and GeoPandas 0.14+ (
pip install "shapely>=2.0" "geopandas>=0.14") — for vectorizedareaandlength. - Valid geometries. Compactness math on self-intersecting rings is unreliable. Run
make_validfirst, per the Understanding OGC Topology Rules preparation steps. - PostGIS 3.1+ (optional) — for
ST_Area,ST_Perimeter, and database-side detection at scale. - A minimum-parcel-area threshold and a compactness threshold. Both are jurisdiction-specific. A common starting point is a minimum area near the smallest legitimate lot in the dataset and a Polsby-Popper score below
0.05. Version-control both alongside the rule.
Step-by-Step Procedure
Step 1 — Compute area and perimeter per parcel
Load the parcels in a projected CRS and derive the two geometric quantities every downstream metric needs. Both are vectorized in GeoPandas.
import geopandas as gpd
def load_with_metrics(path: str, expected_epsg: int) -> gpd.GeoDataFrame:
"""Load parcels and attach area and perimeter in the projected CRS units."""
gdf = gpd.read_file(path)
if gdf.crs is None or gdf.crs.to_epsg() != expected_epsg:
raise ValueError(f"Parcels must be in EPSG:{expected_epsg} before metrics")
if gdf.crs.is_geographic:
raise ValueError("CRS is geographic — area/perimeter need a projected CRS")
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty].copy()
gdf["area"] = gdf.geometry.area
gdf["perimeter"] = gdf.geometry.length
return gdf
Verification: Print gdf[["area", "perimeter"]].describe(). The minimum area should be greater than zero; a zero or near-zero minimum means degenerate geometries slipped through and must be repaired before scoring.
Step 2 — Score compactness with the Polsby-Popper ratio
The Polsby-Popper score, 4 * pi * area / perimeter**2, is a scale-independent thinness signal. A circle scores 1.0; a long thin sliver approaches 0. Because it does not depend on absolute size, it separates thin shapes from compact ones regardless of how large the parcel is.
import math
import numpy as np
def polsby_popper(area: np.ndarray, perimeter: np.ndarray) -> np.ndarray:
"""Vectorized Polsby-Popper compactness: 1.0 is a circle, ~0 is a sliver."""
perimeter = np.where(perimeter == 0, np.nan, perimeter)
return (4.0 * math.pi * area) / (perimeter ** 2)
def add_compactness(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
gdf = gdf.copy()
gdf["compactness"] = polsby_popper(gdf["area"].values, gdf["perimeter"].values)
return gdf
Verification: Score a square test parcel and confirm the value is close to math.pi / 4 (about 0.785); score a long 100 m by 0.5 m strip and confirm the value is well under 0.05. If a square scores near zero, area and perimeter are in mismatched units.
Step 3 — Apply a combined thinness and area rule
Neither test works alone. Area alone flags every small legitimate lot and misses long thin slivers; compactness alone flags long legitimate strips. Flag a candidate only when it is both thin and small.
def flag_slivers(gdf: gpd.GeoDataFrame,
max_area: float = 25.0,
max_compactness: float = 0.05) -> gpd.GeoDataFrame:
"""Flag candidate slivers: low compactness AND small area, together."""
gdf = gdf.copy()
gdf["is_sliver_candidate"] = (
(gdf["compactness"] < max_compactness) &
(gdf["area"] < max_area)
)
n = int(gdf["is_sliver_candidate"].sum())
print(f"Sliver candidates: {n} / {len(gdf)}")
return gdf
Verification: Call gdf.loc[gdf["is_sliver_candidate"], ["area", "compactness"]] and confirm every returned row is genuinely thin and small. A compact utility lot that is small but square must not appear, because its compactness is well above the threshold.
Step 4 — Distinguish true slivers from legitimate narrow features
Some parcels are legitimately thin: access easements, rights of way, drainage strips, railway corridors. These carry a parcel identifier and a recognisable land-use code. Exclude attributed narrow features so only unattributed gaps and overlaps survive.
LEGITIMATE_NARROW_USES = {"easement", "row", "right_of_way", "drainage", "railway"}
def separate_true_slivers(gdf: gpd.GeoDataFrame,
id_col: str = "parcel_id",
use_col: str = "land_use") -> gpd.GeoDataFrame:
"""Keep only candidates that lack a valid ID or a legitimate narrow land-use."""
gdf = gdf.copy()
has_id = gdf[id_col].notna() & (gdf[id_col].astype(str).str.strip() != "")
legit_use = gdf[use_col].fillna("").str.lower().isin(LEGITIMATE_NARROW_USES)
gdf["is_true_sliver"] = (
gdf["is_sliver_candidate"] & ~(has_id & legit_use)
)
cleared = int((gdf["is_sliver_candidate"] & ~gdf["is_true_sliver"]).sum())
print(f"Cleared as legitimate narrow features: {cleared}")
return gdf
Verification: Confirm a known access easement in the test data has is_sliver_candidate = True but is_true_sliver = False. An unattributed gap between two neighbours must retain is_true_sliver = True.
Step 5 — Detect at scale in PostGIS
For parcel tables that are already in a spatial database, compute the same rule in SQL with a GiST index in place. The combined predicate mirrors the Python rule exactly.
-- Candidate slivers: thin (Polsby-Popper) AND small, minus legitimate uses
SELECT
parcel_id,
ST_Area(geom) AS area,
ST_Perimeter(geom) AS perimeter,
(4 * pi() * ST_Area(geom)) / power(ST_Perimeter(geom), 2) AS compactness
FROM parcels
WHERE ST_Perimeter(geom) > 0
AND (4 * pi() * ST_Area(geom)) / power(ST_Perimeter(geom), 2) < 0.05
AND ST_Area(geom) < 25.0
AND (
parcel_id IS NULL
OR lower(coalesce(land_use, '')) NOT IN
('easement', 'row', 'right_of_way', 'drainage', 'railway')
);
Verification: SELECT count(*) from the query and compare against the Python is_true_sliver count on the same extent — the two should match within rounding of the area and perimeter values.
Interpreting Results
Each surviving sliver falls into one of a few causes, and the cause drives the fix. Use the combined signal — where the sliver sits relative to its neighbours — to classify it:
| Signal | Likely cause | Remediation route |
|---|---|---|
| Thin gap between two parcels that should touch | Boundary edge-match failure or digitisation drift | Snap shared vertices to tolerance, then re-check adjacency |
| Thin overlap where two parcels claim the same strip | Independent capture of a shared boundary | Resolve to a single authoritative boundary; adjust the junior parcel |
| Thin polygon along a layer edge after an overlay | Sliver from intersecting two mismatched layers | Dissolve into the larger neighbour or drop below area tolerance |
| Thin but attributed with a valid ID and narrow use | Legitimate easement or right of way | No action — excluded in Step 4 |
Feed the classified true slivers into the severity model in Categorizing and Prioritizing Spatial Errors: a sub-centimetre sliver is informational noise, while a metre-scale overlap between two ownership parcels is a blocker that changes who owns what. The compactness and area values you computed are the exact inputs that severity triage needs, so carry them into the violation record rather than recomputing them downstream.
Gotchas & Edge Cases
Geographic coordinates make every metric meaningless. Polsby-Popper in decimal degrees mixes a degree-squared area with a degree perimeter, and the ratio is not comparable across latitudes. Always score in a projected CRS. If parcels arrive in mixed systems, normalise them with Reprojecting Mixed-CRS Datasets with PyProj first.
A single hard threshold misclassifies boundary cases. A parcel just above the area cutoff but extremely thin is still a sliver, and a compact parcel just below the area cutoff is not. Keep both thresholds tunable and review the band immediately around each cutoff by hand during the first runs, rather than trusting a single fixed number.
MultiPolygon parcels distort compactness. A parcel stored as a MultiPolygon sums the areas but its perimeter includes every part boundary, so the ratio understates compactness and produces false sliver flags. Explode to single-part geometries before scoring, or score each part independently and aggregate.
Interior rings inflate perimeter. A donut parcel with a large hole has a long total perimeter relative to its net area, which can push its compactness below the threshold even though it is a legitimate lot. Exclude parcels with interior rings from the pure thinness test, or compute compactness on the exterior ring only when a hole is expected.
Slivers can be legitimate cadastral remnants. In some jurisdictions a genuine, legally recorded parcel really is a thin strip — a historic access lane, a gore between road reservations. Attribute-based exclusion in Step 4 is what protects these; never delete a thin parcel purely on geometry without checking its identifier and land-use code.
When to Escalate
The per-feature approach here identifies slivers by shape. Escalate to a heavier, relational method when:
- Slivers must be tied to the specific neighbours that produced them. Classifying a sliver as gap versus overlap requires cross-feature predicates —
ST_Touches,ST_Overlaps, and difference operations against adjacent parcels. That relational analysis belongs to the topology rule matrix in Understanding OGC Topology Rules and, for jurisdiction-specific tolerances, How to Define Topology Rules for Cadastral Maps. - The parcel table exceeds a few hundred thousand features. A per-feature Python pass remains single-threaded; move detection into PostGIS with a GiST index, or partition by administrative boundary and process in parallel.
- Findings must be triaged and dispatched automatically. Once slivers are found, routing them by severity to the right owner is its own concern, covered in Categorizing and Prioritizing Spatial Errors.
- Remediation must be automated and auditable. Snapping, dissolving, and re-validating slivers at scale is a repair workflow, not a detection one, and should run as versioned, logged operations rather than manual edits.
Frequently Asked Questions
What is a sliver polygon in parcel data?
A sliver polygon is a thin, low-area feature that usually represents an unintended gap or overlap between adjacent parcels rather than a real land unit. It typically arises from digitisation drift, imperfect edge matching, or overlay operations between layers whose boundaries were captured independently. Slivers are characterised by a very low compactness score and an area far below the smallest legitimate parcel, which is why the combined rule in Step 3 targets both signals at once.
Why is an area threshold alone not enough to detect slivers?
Area alone flags every small parcel, including legitimate ones such as utility lots and pocket parks, while missing long thin slivers that run along a boundary and accumulate enough area to pass a size filter. Combining a thinness or compactness ratio with the area threshold catches long thin slivers and spares small but compact legitimate parcels. The Polsby-Popper score in Step 2 supplies the thinness half of that combined rule.
What is the Polsby-Popper compactness score?
Polsby-Popper is a shape compactness measure defined as 4 * pi * area / perimeter**2. A perfect circle scores 1.0; long thin shapes approach 0. For sliver detection it is a scale-independent thinness signal, so a threshold around 0.03 to 0.10 isolates slivers regardless of parcel size, unlike a raw area cutoff. Score in a projected CRS so area and perimeter share consistent linear units.
How do I avoid flagging legitimate narrow features as slivers?
Cross-check thin candidates against attributes and adjacency before flagging, as in Step 4. Access easements, rights of way, drainage strips, and railway corridors are legitimately thin but carry a valid parcel identifier and a recognised land-use code. Exclude candidates that have both a valid identifier and a narrow-feature land-use class, and only flag the ones that are unattributed gaps or overlaps between neighbours.
Related:
- Understanding OGC Topology Rules — the topology model in which slivers are boundary and gap violations
- How to Define Topology Rules for Cadastral Maps — jurisdiction-specific tolerances that set your sliver thresholds
- Categorizing and Prioritizing Spatial Errors — severity triage for the true slivers this page surfaces
Back to Understanding OGC Topology Rules