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.
Deduplicating Overlapping Polygons with Spatial Joins
The Problem: Duplicate Footprints Are Not Byte-Identical
When two pipelines ingest the same building footprints, parcels, or field boundaries from different vendors, the “duplicate” polygons almost never share identical coordinates — so a hash-based deduplication step passes them straight through as distinct features.
Vendor A digitized the parcel at one scale, vendor B at another; a reprojection nudged every vertex a few centimetres; one source has an extra vertex on a straight run the other collapsed. The polygons cover the same ground but differ byte-for-byte, so exact-match deduplication is blind to them. The reliable signal is area agreement: two polygons that overlap almost completely are the same feature. Intersection-over-union (IoU) turns that intuition into a single number you can threshold.
- Double-counted areas: summing an attribute (population, yield, footprint area) over a layer with duplicate polygons overstates every total.
sjoinfan-out: a downstream join against a duplicated reference layer multiplies matched rows, silently inflating result cardinality.- Conflicting attributes: two copies of one parcel carry different owner or class values, and whichever is written last wins by accident rather than by rule.
- Naive pairwise comparison is O(n²): comparing every polygon against every other is infeasible past a few thousand features, which is why the spatial index has to prune candidates first.
Version and Environment Compatibility
| GeoPandas | Shapely | Recommended dedup path | Caveat |
|---|---|---|---|
| ≥ 1.0 | ≥ 2.0 | gpd.sjoin + vectorized shapely.intersection/union |
Stable predicate-based join; use this going forward |
| 0.13–0.14 | 2.0.x | gpd.sjoin + vectorized IoU |
sjoin returns index_right; confirm column name after reset_index |
| 0.10–0.12 | 1.8.x | sjoin + per-geometry area |
No vectorized array API; IoU loop is slow on large frames |
Install the recommended stack:
pip install "geopandas>=1.0.0" "shapely>=2.0.0" numpyHow IoU Decides a Duplicate
IoU ranges from 0 (disjoint) to 1 (identical). A threshold of 0.9 treats only near-perfect matches as duplicates and leaves partially overlapping neighbours intact; lowering it toward 0.5 merges looser matches at the risk of collapsing genuinely distinct-but-adjacent polygons. The spatial self-join does the pruning — it returns only pairs whose bounding boxes intersect — and IoU is computed just for those candidates, keeping the whole operation near-linear in practice.
Recipe: dedupe_overlapping_polygons
The function builds a GeoPandas spatial self-join, scores each candidate pair with the vectorized Shapely array API, groups duplicates into connected components, and keeps one survivor per group by a configurable rule.
import logging
import geopandas as gpd
import numpy as np
import shapely
logger = logging.getLogger(__name__)
def dedupe_overlapping_polygons(
gdf: gpd.GeoDataFrame,
iou_threshold: float = 0.9,
priority_col: str | None = None,
keep: str = "first",
) -> gpd.GeoDataFrame:
"""
Collapse duplicate / overlapping polygons detected by a spatial self-join.
Two polygons are treated as the same feature when their intersection-over-
union (IoU) meets iou_threshold. Candidate pairs come from a GeoPandas
spatial self-join (predicate='intersects'), and IoU is computed with the
Shapely 2.0 vectorized array API — no per-row .apply().
Parameters
----------
gdf : GeoDataFrame
Polygon layer in a projected (metric) CRS.
iou_threshold : float
Minimum IoU (0-1) for two polygons to merge into one group. 0.9 is a
strict "near-identical" default.
priority_col : str | None
Column whose highest value wins the survivor slot within a group
(e.g. a source-quality score or capture timestamp).
keep : {'first', 'largest'}
Fallback survivor rule when priority_col is None.
Returns
-------
GeoDataFrame
One representative polygon per duplicate group, in the original CRS.
"""
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError("Project to a metric CRS before area-based IoU dedup.")
if len(gdf) < 2:
return gdf.copy()
work = gdf.reset_index(drop=True)
geom = work.geometry.values # GeometryArray for vectorized ops
# 1. Candidate pairs: self-join returns every bounding-box-intersecting pair.
joined = gpd.sjoin(
work[["geometry"]], work[["geometry"]],
predicate="intersects", how="inner",
)
left = joined.index.to_numpy()
right = joined["index_right"].to_numpy()
# 2. Keep ordered, non-self pairs only (i < j) to halve the work.
ordered = left < right
left, right = left[ordered], right[ordered]
if len(left) == 0:
return gdf.copy()
# 3. Vectorized IoU across all candidate pairs in two GEOS calls.
inter = shapely.area(shapely.intersection(geom[left], geom[right]))
union = shapely.area(shapely.union(geom[left], geom[right]))
with np.errstate(divide="ignore", invalid="ignore"):
iou = np.where(union > 0, inter / union, 0.0)
dup = iou >= iou_threshold
dup_left, dup_right = left[dup], right[dup]
logger.info(
"sjoin found %d intersecting pairs; %d exceed IoU %.2f",
len(left), int(dup.sum()), iou_threshold,
)
# 4. Union-find so transitive overlaps (A~B, B~C) collapse into one group.
parent = np.arange(len(work))
def find(i: int) -> int:
while parent[i] != i:
parent[i] = parent[parent[i]] # path halving
i = parent[i]
return i
for i, j in zip(dup_left.tolist(), dup_right.tolist()):
ri, rj = find(i), find(j)
if ri != rj:
parent[ri] = rj
roots = np.array([find(i) for i in range(len(work))])
# 5. Choose one survivor index per group; higher order value wins.
if priority_col is not None:
order = work[priority_col].to_numpy()
elif keep == "largest":
order = shapely.area(geom)
else: # 'first' — lowest original position wins
order = -np.arange(len(work))
survivors = [
members[np.argmax(order[members])]
for members in (np.where(roots == r)[0] for r in np.unique(roots))
]
result = work.iloc[sorted(survivors)].reset_index(drop=True)
logger.info(
"dedupe_overlapping_polygons: %d -> %d features (removed %d)",
len(work), len(result), len(work) - len(result),
)
return resultKey Implementation Notes
- The self-join prunes; IoU decides.
gpd.sjoinuses the GEOS STRtree to return only pairs whose envelopes intersect, so the expensive area math runs on a short candidate list rather than the full n² grid. This is the same spatial-index discipline that makes large-layer deduplication tractable at all. i < jremoves identity and mirror pairs in one step. A self-join returns each polygon paired with itself and each real pair twice (A–B and B–A). Filtering to strictly increasing index pairs drops both classes without a separate de-mirroring pass.- IoU is fully vectorized.
shapely.intersection(geom[left], geom[right])andshapely.union(...)each dispatch a single GEOS call over the whole candidate array; dividing the two area arrays gives every IoU at once. There is no Python-level loop over geometry pairs. - Union-find captures transitive duplicates. Three near-identical copies where A overlaps B and B overlaps C but A and C are slightly offset still belong to one group. Connected-component merging guarantees they collapse to a single survivor, which a naive keep-one-of-each-pair rule would miss.
- The survivor rule is explicit, not incidental. Passing
priority_col(a quality flag, a source rank, an ingest timestamp) makes the kept feature a deliberate choice. Thekeep='first'default is only a fallback and depends on stable row order, so prefer an explicit priority in production. - Points need a different tool. This routine is area-based and only meaningful for polygons; near-duplicate point layers use a distance radius instead, covered in removing duplicate spatial points with tolerance thresholds.
Troubleshooting Overlap Deduplication
| Symptom | Root Cause | Fix |
|---|---|---|
| Every polygon pairs with itself | Self-join includes identity matches | Filter left < right to drop identity and mirror pairs |
| Distinct-but-touching polygons wrongly merged | predicate='intersects' matches edge touches |
Use predicate='overlaps' or raise iou_threshold |
MemoryError on the self-join |
Dense overlaps produce O(n²) candidate pairs | Tile by a spatial index and dedupe per tile with a boundary buffer pass |
| The wrong copy is kept | keep='first' ignores data quality |
Pass priority_col with a quality score or timestamp |
| Partial overlaps survive dedup | IoU threshold too high for offset digitizations | Lower iou_threshold or switch predicate to overlaps |
Integration Note
Run this step in the Transform stage after geometry repair and before any attribute aggregation, so downstream sums are computed over a deduplicated layer. It pairs naturally with the other routines in spatial deduplication and topology simplification: resolve overlapping source polygons here first, then hand the clean partition to simplifying polygons while preserving shared boundaries, because simplifying an unresolved overlap only hides it. Where duplicate copies carry conflicting attributes, reconcile the columns using the rules in attribute mapping and schema harmonisation before choosing a survivor, so the kept row also carries the authoritative values.
Related
- Spatial Deduplication & Topology Simplification — the full deduplication and topology workflow this recipe belongs to
- Simplifying Polygons While Preserving Shared Boundaries — simplify the clean partition without opening gaps once duplicates are resolved
- Removing Duplicate Spatial Points with Tolerance Thresholds — the distance-based equivalent for point layers
- Attribute Mapping and Schema Harmonisation — reconcile conflicting attributes across duplicate copies before picking a survivor