This guide is part of Spatial Deduplication & Topology Simplification, which sits within the Automated Vector & Raster Cleaning Workflows reference for building reliable spatial ETL pipelines.
Simplifying Polygons While Preserving Shared Boundaries
The Problem: Independent Simplification Tears Shared Edges Apart
Running shapely.simplify over a layer of adjacent polygons removes vertices from each polygon in complete isolation β and that is exactly why it opens gaps and overlaps along every border two features share.
A shared boundary is not stored once. In a standard GeoDataFrame of parcels, administrative units, or land-cover classes, the edge between two neighbours is stored twice: as part of the ring of polygon A and again as part of the ring of polygon B. The Douglas-Peucker algorithm decides independently which vertices to drop from each copy. When it removes a bend from Aβs copy but keeps it in Bβs, the two edges no longer coincide, and the layer that was a perfect coverage becomes a mosaic of hairline slivers.
sjoinovercounting: overlap slivers make a spatial join match the same target feature from two neighbours, inflating aggregation results.- PostGIS coverage failures: gap slivers break
ST_Coverageand topology validation, and rasterizing the layer leaves unassigned pixels along every seam. preserve_topology=Truedoes not save you: that flag only keeps each polygon individually valid; it has no visibility into the neighbour that shares the edge.- Invisible at map scale: a 0.4 m sliver between two 2 km parcels never shows on screen, so the damage passes review and surfaces only when a downstream query returns wrong numbers.
Version and Environment Compatibility
| GeoPandas | Shapely | GEOS | Recommended approach | Caveat |
|---|---|---|---|---|
| β₯ 1.0 | β₯ 2.0 | β₯ 3.11 | shapely.simplify(arr) + dissolved-area check |
Fast, but cannot preserve shared edges β validate coverage |
| 0.13β0.14 | 2.0.x | β₯ 3.9 | shapely.simplify(arr) + union_all check |
union_all available; prefer over deprecated cascaded_union |
| < 0.13 | 1.8.x | β₯ 3.8 | topology-aware library (topojson) |
No vectorized array API; per-geometry simplify is slow and unsafe |
Install the recommended stack:
pip install "geopandas>=1.0.0" "shapely>=2.0.0" "topojson>=1.8" numpyWhy the Shared Edge Drifts
The right panel is what a topology-aware simplifier such as the topojson Python library or the command-line mapshaper tool does: it builds an arc topology where each shared boundary is a single line simplified exactly once, then rebuilds the polygons from those arcs. Shapely has no such abstraction β it operates on independent geometries β so the practical strategy in a Python pipeline is to simplify with the fast vectorized call, then measure whether the shared edges survived, and escalate to an arc-based tool only for the layers that fail.
Recipe: simplify_preserving_shared_boundaries
The function below simplifies the whole geometry array in one vectorized pass, repairs any invalid outputs, then dissolves the layer before and after to quantify gap and overlap area. It raises when the damage exceeds a configured ratio so the pipeline can route that layer to a topology-aware tool.
import logging
import geopandas as gpd
import numpy as np
import shapely
logger = logging.getLogger(__name__)
def simplify_preserving_shared_boundaries(
gdf: gpd.GeoDataFrame,
tolerance: float,
max_gap_ratio: float = 1e-4,
max_overlap_ratio: float = 1e-4,
) -> tuple[gpd.GeoDataFrame, dict]:
"""
Simplify polygons and verify no gaps or overlaps opened on shared edges.
Uses the Shapely 2.0 vectorized API (shapely.simplify over the whole
GeometryArray). Because shapely.simplify treats every polygon in isolation,
adjacent features that share an edge can drift apart. This function performs
the simplification, then compares the dissolved coverage before and after to
quantify any topology damage, raising when it exceeds the configured ratios.
Parameters
----------
gdf : GeoDataFrame
Polygon layer in a projected (metric) CRS.
tolerance : float
Douglas-Peucker distance tolerance in CRS units (metres for UTM).
max_gap_ratio : float
Maximum coverage loss, as a fraction of original dissolved area.
max_overlap_ratio : float
Maximum introduced overlap, as a fraction of original dissolved area.
Returns
-------
(GeoDataFrame, dict)
The simplified frame and a metrics dict for pipeline observability.
Raises
------
RuntimeError
If gap or overlap area exceeds the configured ratio, signalling that an
arc-based topology tool is required for this layer.
"""
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError(
"Project to a metric CRS first β degree tolerances vary with "
"latitude and corrupt the shared-edge comparison."
)
geom = gdf.geometry.values # GeometryArray β vectorized Shapely 2.0 input
# Dissolve once to capture the true coverage footprint before vertices move.
area_before = shapely.area(shapely.union_all(geom))
# Single C-level Douglas-Peucker pass across every polygon β never .apply().
simplified = shapely.simplify(geom, tolerance, preserve_topology=True)
# A collapsed neck or hole can invalidate a polygon; repair before measuring.
invalid = ~shapely.is_valid(simplified)
if invalid.any():
logger.warning("%d polygons invalid after simplify β repairing", int(invalid.sum()))
simplified = np.where(invalid, shapely.make_valid(simplified), simplified)
area_after = shapely.area(shapely.union_all(simplified))
summed_after = float(shapely.area(simplified).sum())
# Gap: coverage the dissolve lost. Overlap: area double-counted because
# neighbouring edges now cross into each other.
gap_area = max(area_before - area_after, 0.0)
overlap_area = max(summed_after - area_after, 0.0)
gap_ratio = gap_area / area_before if area_before else 0.0
overlap_ratio = overlap_area / area_before if area_before else 0.0
metrics = {
"features": len(gdf),
"tolerance": tolerance,
"vertices_before": int(shapely.get_num_coordinates(geom).sum()),
"vertices_after": int(shapely.get_num_coordinates(simplified).sum()),
"gap_area": round(gap_area, 4),
"overlap_area": round(overlap_area, 4),
"gap_ratio": gap_ratio,
"overlap_ratio": overlap_ratio,
}
logger.info("shared-boundary simplify: %s", metrics)
if gap_ratio > max_gap_ratio or overlap_ratio > max_overlap_ratio:
raise RuntimeError(
f"Simplification damaged shared boundaries "
f"(gap_ratio={gap_ratio:.2e}, overlap_ratio={overlap_ratio:.2e}). "
"Use an arc-based tool (topojson / mapshaper) for this layer."
)
result = gdf.copy()
result.geometry = simplified
return result, metricsKey Implementation Notes
- The dissolved-area comparison is the whole point.
shapely.union_allcollapses the layer into one coverage geometry. For a clean partition the summed individual area equals the dissolved area; after independent simplification, overlaps pushsummed_afterabovearea_afterwhile gaps pullarea_afterbelowarea_before. The two subtractions isolate each failure mode without ever comparing polygons pairwise. preserve_topology=Trueis necessary but not sufficient. It stops a single polygon from self-intersecting when a bend is removed, which is why the recipe keeps it on. It says nothing about the neighbour on the other side of the shared edge β that gap is the reason the coverage check exists at all.make_validruns only on the invalid subset. Wrapping the whole array inmake_validunconditionally would waste GEOS calls on already-valid polygons and can subtly re-order rings. Thenp.where(invalid, ...)guard repairs only what broke.- Ratios, not absolute areas, are the gate. A 0.5 mΒ² sliver is catastrophic in a parcel layer and negligible in a continental land-cover layer. Comparing against
area_beforemakes the threshold scale-independent so the same function works for both. - Escalation is cheaper than always using the slow path. Arc-based simplification via a topology-aware library is correct but far slower and memory-heavier on large layers. Simplifying fast and validating means only the layers that genuinely need arc topology pay that cost. Snapping shared vertices to a grid first, as covered in handling precision and coordinate rounding, often removes the drift entirely and lets the fast path pass.
Troubleshooting Shared-Boundary Simplification
| Symptom | Root Cause | Fix |
|---|---|---|
| Sliver gaps appear between neighbours after simplify | Each polygonβs shared edge was simplified independently and drifted | Switch to arc-based simplification (topojson / mapshaper); Shapely cannot preserve edges across separate rows |
RuntimeError raised even at a tiny tolerance |
Input polygons were not a clean partition to begin with | Snap shared vertices to a common grid before simplifying |
| Polygons invalid after simplify | Tolerance collapsed a thin neck or a hole | make_valid recovers most; reduce the tolerance for the rest |
Overlap ratio passes but sjoin still overcounts |
Overlap smaller than max_overlap_ratio but still crosses a boundary |
Lower max_overlap_ratio and re-run at a smaller tolerance |
| Degree-based tolerance gives inconsistent results | Layer is in EPSG:4326 | Project to a metric CRS before calling the function |
Integration Note
This routine belongs in the Transform stage, immediately after geometry repair and before the layer is loaded or joined. It composes directly with the vectorized deduplication steps in spatial deduplication and topology simplification: dedupe first so you are not simplifying redundant copies of the same edge, then simplify with the coverage check here. When the input is a set of overlapping source polygons rather than a clean partition, resolve those overlaps first with the spatial-join approach in deduplicating overlapping polygons with spatial joins, because simplifying an unresolved overlap only masks it. Emit the returned metrics dict to your orchestrator so a rising gap ratio triggers an alert rather than a silent coverage hole.
Related
- Spatial Deduplication & Topology Simplification β the full deduplication and topology-preserving simplification workflow this recipe plugs into
- Deduplicating Overlapping Polygons with Spatial Joins β resolve overlapping source polygons into a clean partition before simplifying
- Handling Precision and Coordinate Rounding β grid-snap shared vertices so independent simplification stops drifting
- Geometry Repair with Shapely & GeoPandas β fix invalid rings before and after simplification