Fixing sliver polygons in spatial join operations
This page solves one precise failure in the catchment enrichment pipeline: the microscopic gap and overlap polygons — slivers — that appear when retail trade areas are overlaid against census geometry, and the deterministic GeoPandas routine that detects and removes them before they corrupt demographic aggregation.
Slivers emerge during overlay operations, coordinate reference system transformations, or when merging datasets with differing vertex densities and floating-point precision limits. When drive-time isochrones, custom buffer zones, or municipal boundaries are intersected with census block groups and tract geometry, sub-metre coordinate mismatches generate polygons often smaller than 500 m². Left in place, these artifacts silently double-count households, misallocate median income, and distort revenue forecasts. Resolving them is a hard data-integrity gate for any team running Performing Point-in-Polygon Joins for Store Catchments inside an automated demographic data integration workflow.
Prerequisites
Before running the remediation routine below, confirm the following are in place:
- Python packages:
geopandas>=0.14,shapely>=2.0(the 2.xmake_validand vectorized buffer API are assumed), andpyprojfor CRS handling. Shapely 2.x is required because the area and buffer operations here are vectorized over theGeoSeries. - A projected CRS on every input layer. Area thresholds are meaningless in degrees. Reproject both the catchment layer and the reference layer to a metric CRS — a regional UTM zone (e.g. EPSG:32617 for the eastern US) or EPSG:5070 (NAD83 / Conus Albers) for national footprints. CRS standardization is covered in the source schema design at Setting Up PostGIS for Retail Analytics.
- Valid input geometry, or a tolerance for repairing it. Self-intersections and bad ring orientation precede most sliver formation, so the routine repairs topology with
make_valid()before measuring area. - The two layers being joined. Typically a trade area polygon layer (isochrones or buffers) and a census layer such as block groups synced via Syncing US Census ACS Data via API.
Configuration and execution parameters
Sliver remediation is configuration-driven, not a manual cartographic edit. The following parameters control detection sensitivity and the remediation aggressiveness. Tune them per market density rather than hard-coding a single value.
| Parameter | Type | Valid range | Retail default | Purpose |
|---|---|---|---|---|
crs |
EPSG code | any projected CRS | EPSG:5070 |
Metric CRS for accurate area in m² |
sliver_area_threshold_m2 |
float | 250 – 5000 |
1000.0 |
Area below which a polygon is a sliver |
snap_tolerance_m |
float | 0.5 – 2.0 |
1.0 |
Vertex snapping band between layers |
buffer_close_dist_m |
float | 0.25 – 1.0 |
0.5 |
Negative-then-positive buffer distance |
max_allowed_sliver_pct |
float | 0.0 – 0.01 |
0.005 |
Fraction of total area allowed as slivers |
validation_mode |
enum | strict / warn / bypass |
strict |
Pipeline behaviour when the gate trips |
Calibrate sliver_area_threshold_m2 to market density: in dense urban markets 1000 m² isolates true artifacts without consuming real blocks, while rural and exurban zones tolerate 5000 m². Set snap_tolerance_m to the positional accuracy of the source data — 0.5–2.0 m for municipal parcel data or Census TIGER/Line files. A YAML representation of these settings keeps the stage reproducible across planning cycles:
spatial_join_config:
crs: "EPSG:5070"
sliver_area_threshold_m2: 1000.0
snap_tolerance_m: 1.0
buffer_close_dist_m: 0.5
max_allowed_sliver_pct: 0.005 # 0.5% of total catchment area
validation_mode: "strict" # strict | warn | bypass
Detection and remediation implementation
The routine has three stages — detect, snap-and-close, and gate — wired so the same config dict drives all of them. Each function asserts the CRS is projected before it touches an area calculation, which is the single most common cause of silent failure.
import geopandas as gpd
import logging
from shapely.ops import snap
from shapely.validation import make_valid
logger = logging.getLogger(__name__)
def _assert_projected(gdf: gpd.GeoDataFrame) -> None:
"""Fail fast if area math would run in degrees."""
if gdf.crs is None or not gdf.crs.is_projected:
raise ValueError("CRS must be projected to calculate area in square metres.")
def detect_slivers(gdf: gpd.GeoDataFrame, threshold_m2: float = 1000.0) -> gpd.GeoDataFrame:
"""Return rows whose polygon area falls below the configured threshold."""
_assert_projected(gdf)
# Repair topology BEFORE measuring area: self-intersections inflate area errors.
gdf = gdf.copy()
gdf["geometry"] = gdf.geometry.apply(lambda g: make_valid(g) if g else g)
area = gdf.geometry.area
mask = area < threshold_m2
if mask.any():
logger.warning(
"Detected %d sliver polygons below %.0f m2; max sliver area %.2f m2",
int(mask.sum()), threshold_m2, float(area[mask].max()),
)
return gdf[mask]
def apply_snapping(target: gpd.GeoDataFrame,
reference: gpd.GeoDataFrame,
tolerance: float = 1.0) -> gpd.GeoDataFrame:
"""Snap target vertices onto the reference layer to close sub-pixel gaps."""
_assert_projected(target)
snapped = [
snap(t, r, tolerance)
for t, r in zip(target.geometry, reference.geometry)
]
return target.set_geometry(snapped, crs=target.crs)
def morphological_close(gdf: gpd.GeoDataFrame, buffer_dist: float = 0.5) -> gpd.GeoDataFrame:
"""Negative-then-positive buffer: collapses thin gaps without moving the boundary."""
_assert_projected(gdf)
closed = gdf.geometry.buffer(-buffer_dist).buffer(buffer_dist)
# Small polygons can buffer to empty/invalid geometry — re-validate.
closed = closed.apply(lambda g: make_valid(g) if g and not g.is_empty else g)
return gdf.set_geometry(closed, crs=gdf.crs)
def remediate(catchments: gpd.GeoDataFrame,
reference: gpd.GeoDataFrame,
config: dict) -> gpd.GeoDataFrame:
"""Full deterministic pass: reproject, snap, close, then gate on residual slivers."""
crs = config["crs"]
catchments = catchments.to_crs(crs)
reference = reference.to_crs(crs)
total_area = catchments.geometry.area.sum()
fixed = apply_snapping(catchments, reference, config["snap_tolerance_m"])
fixed = morphological_close(fixed, config["buffer_close_dist_m"])
residual = detect_slivers(fixed, config["sliver_area_threshold_m2"])
residual_pct = residual.geometry.area.sum() / total_area if total_area else 0.0
if residual_pct > config["max_allowed_sliver_pct"]:
msg = (f"Residual sliver area {residual_pct:.4%} exceeds "
f"{config['max_allowed_sliver_pct']:.4%} budget.")
if config["validation_mode"] == "strict":
raise RuntimeError(msg)
logger.error(msg)
return fixed
For point-in-polygon joins specifically, slivers are mitigated a second way: enforce a minimum intersection area when reconciling overlapping catchments. GeoPandas sjoin exposes the spatial predicate, but the area filter must run after the join so duplicate point attribution is removed deterministically rather than being silently merged.
Failure modes and debugging
| Symptom | Root cause | Fix |
|---|---|---|
ValueError: CRS must be projected |
Layer still in EPSG:4326 (degrees) | to_crs() to a metric CRS before area math |
| Area threshold removes real blocks | Threshold too high for market density | Lower to 1000 m² urban; inspect area histogram |
make_valid returns a GeometryCollection |
Mixed point/line/polygon debris from a bad overlay | Filter to polygonal parts with .geometry.buffer(0) or extract polygons before measuring |
| Buffer produces empty geometry | Polygon narrower than 2 × buffer_close_dist_m |
Reduce buffer_close_dist_m; re-validate after closing |
| Slivers persist after snapping | Layers have non-coincident vertices outside the tolerance band | Raise snap_tolerance_m toward source positional accuracy |
| Centroid jumps after remediation | Closing distance too aggressive | Reduce buffer_close_dist_m; verify centroid stability (below) |
A CRS mismatch is the dominant failure: if either layer is unprojected, areas come back in square degrees and every polygon reads as a sliver. The _assert_projected guard turns that from a silent corruption into an immediate exception. When debugging a stubborn overlay, log structured JSON containing pipeline_run_id, source_layer_version, geometry_hash, and timestamp so attribution drift across quarterly ACS refreshes can be traced to a specific layer version.
Verification
Confirm correct output against measurable ground-truth tolerances before the layer feeds any downstream model. Tie this into the broader checks documented in Validating Spatial Join Accuracy with Ground Truth.
- Area delta. Total catchment area must deviate less than
0.1%from the pre-remediation total:abs(after - before) / before < 0.001. - Centroid stability. Euclidean distance between pre- and post-remediation centroids should stay under
50 m. Flag any polygon that shifts further — it indicates over-aggressive closing. - Topology consistency. Run
shapely.validation.explain_validity()on a random 5% sample and confirm zero residual self-intersections. - Row-count parity. Sliver removal must not drop whole records: the output row count equals the input row count (slivers are merged away, not deleted as rows).
- Demographic continuity. Re-aggregate household counts and median income against the pre-join baseline; deviations should sit inside US Census Bureau margin-of-error guidance.
When these five checks pass, the remediated layer is safe to feed into the next stage — point assignment and the demographic weighting that follows in Weighting Demographic Variables for Target Audiences.
Related
- Performing Point-in-Polygon Joins for Store Catchments — the parent join workflow this remediation guards.
- Validating Spatial Join Accuracy with Ground Truth — the verification stage downstream of sliver removal.
- Imputing Missing Census Block Group Data — handling gaps once geometry is clean.
← Back to Performing Point-in-Polygon Joins for Store Catchments