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.


Sliver polygon detection decision flow Parcel geometries flow into a metrics stage computing area and perimeter, then a Polsby-Popper compactness scorer, then a combined rule gate testing both low compactness and small area; candidates that pass the gate split into legitimate narrow features, which are cleared, and true slivers, which are routed to remediation. Parcel Geometries Metrics area · perimeter Polsby-Popper Combined Rule thin AND small Legitimate easement · ROW cleared True Slivers gap · overlap unattributed Remediation queue + severity attributed unattributed

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 vectorized area and length.
  • Valid geometries. Compactness math on self-intersecting rings is unreliable. Run make_valid first, 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:

Back to Understanding OGC Topology Rules