This guide is part of Spatial Deduplication & Topology Simplification, which sits within the Automated Vector & Raster Cleaning Workflows pipeline reference.
Removing Duplicate Spatial Points with Tolerance Thresholds in Python
Spatial point datasets routinely carry near-duplicate features — two records representing the same physical location that differ by a few meters because of GPS drift, digitization variance, or floating-point rounding during CRS reprojection. This page shows how to collapse those near-duplicates into single representative points using a tolerance threshold, without triggering the O(n²) memory cost of a naïve pairwise distance matrix.
Why exact coordinate equality silently corrupts spatial pipelines
Raw equality checks (x1 == x2 and y1 == y2) pass silently when two features are logically the same point but numerically distinct. The failures surface far downstream:
- Spatial join inflation: a
sjoinbetween a deduplicated reference layer and a raw sensor feed counts each near-duplicate as a separate match, inflating join cardinality and skewing aggregations. - Topology validation false positives: overlapping or nearly coincident points trigger
TopologicalErrorin Shapely’sis_validcheck and causedissolveto produce slivers that break geometry repair workflows. - Routing and network graph errors: building a network from point nodes that include near-duplicates creates disconnected micro-edges that inflate path costs and break shortest-path queries.
- Silent data loss after rasterization: rasterizing a point layer with duplicates applies the last-written value per pixel, discarding earlier features entirely with no warning.
Version and environment compatibility
| GeoPandas | Shapely | SciPy | GEOS | Notes |
|---|---|---|---|---|
| 0.12+ | 2.0+ | 1.9+ | 3.11+ | estimate_utm_crs() available; vectorized Shapely array API used |
| 0.10–0.11 | 1.8.x | 1.7+ | 3.9–3.10 | Replace estimate_utm_crs() with to_crs(epsg=32632) or similar fixed UTM zone |
| 0.9 | 1.7.x | 1.6+ | 3.8 | cKDTree.query_ball_point available; union-find approach unchanged |
| <0.9 | <1.7 | any | <3.8 | Upgrade required; older GeoPandas lacks reliable CRS inference |
Install the required stack:
pip install geopandas>=0.12 shapely>=2.0 scipy>=1.9 numpy>=1.24Confirm your GEOS version at runtime:
import shapely
print(shapely.geos_version_string) # expect 3.11.x or laterSVG: union-find clustering algorithm flow
Production-ready function: deduplicate_points_tolerance
The function below auto-projects to a metric CRS, builds a cKDTree, runs union-find, and returns a cleaned GeoDataFrame. It uses the Shapely 2.0 vectorized array API (geometry.x, geometry.y on the full column) rather than row-by-row .apply().
import logging
import geopandas as gpd
import numpy as np
from scipy.spatial import cKDTree
from shapely.geometry import Point
logger = logging.getLogger(__name__)
def deduplicate_points_tolerance(
gdf: gpd.GeoDataFrame,
tolerance_meters: float,
method: str = "first",
) -> gpd.GeoDataFrame:
"""
Remove near-duplicate point features within a tolerance threshold.
Parameters
----------
gdf : GeoDataFrame
Input point layer; must contain only Point geometries.
tolerance_meters : float
Maximum centre-to-centre distance (m) to treat two points as duplicates.
method : {'first', 'centroid'}
'first' — keep the row with the lowest original index per cluster.
'centroid' — replace geometry with the arithmetic mean of cluster coords
and copy non-spatial attributes from the first row.
Returns
-------
GeoDataFrame
Deduplicated point layer in the same CRS as the input.
Raises
------
ValueError
If the GeoDataFrame contains non-Point geometries or an unknown method.
"""
if gdf.empty:
logger.info("deduplicate_points_tolerance: empty GeoDataFrame, returning as-is")
return gdf.copy()
if not all(gdf.geometry.geom_type == "Point"):
raise ValueError(
"deduplicate_points_tolerance requires a Point-only GeoDataFrame. "
"Found mixed or non-Point geometry types."
)
original_crs = gdf.crs
# Project to metric CRS for accurate Euclidean distance in metres.
# estimate_utm_crs() picks the locally best UTM zone (GeoPandas ≥ 0.12).
if gdf.crs is None or gdf.crs.is_geographic:
metric_crs = gdf.estimate_utm_crs() if gdf.crs is not None else "EPSG:3857"
gdf = gdf.to_crs(metric_crs)
logger.debug("Projected to %s for distance calculations", gdf.crs.to_string())
# Shapely 2.0 vectorized coordinate extraction — no .apply()
coords = np.column_stack([gdf.geometry.x.to_numpy(), gdf.geometry.y.to_numpy()])
# O(n log n) spatial index
tree = cKDTree(coords)
# Each entry: list of indices within tolerance_meters of point i
neighbor_lists = tree.query_ball_point(coords, r=tolerance_meters)
# ------------------------------------------------------------------ #
# Union-Find with path compression
# Neighbour relations are transitive: if A~B and B~C, then {A,B,C}
# form one duplicate cluster even if dist(A,C) > tolerance.
# ------------------------------------------------------------------ #
parent = np.arange(len(gdf), dtype=np.int64)
def find(i: int) -> int:
"""Iterative find with path halving."""
while parent[i] != i:
parent[i] = parent[parent[i]] # path halving
i = parent[i]
return i
def union(i: int, j: int) -> None:
ri, rj = find(i), find(j)
if ri != rj:
parent[ri] = rj
for i, nbrs in enumerate(neighbor_lists):
for j in nbrs:
if i < j:
union(i, j)
# Group original indices by cluster root
clusters: dict[int, list[int]] = {}
for i in range(len(gdf)):
clusters.setdefault(find(i), []).append(i)
# Build one representative row per cluster
keep_rows = []
for indices in clusters.values():
if method == "first":
keep_rows.append(gdf.iloc[indices[0]])
elif method == "centroid":
sub = gdf.iloc[indices]
mean_x = float(sub.geometry.x.mean())
mean_y = float(sub.geometry.y.mean())
row = sub.iloc[0].copy()
row.geometry = Point(mean_x, mean_y)
keep_rows.append(row)
else:
raise ValueError(f"Unknown method '{method}'. Choose 'first' or 'centroid'.")
result = gpd.GeoDataFrame(keep_rows, crs=gdf.crs).reset_index(drop=True)
# Reproject back to original CRS if we changed it
if result.crs != original_crs and original_crs is not None:
result = result.to_crs(original_crs)
removed = len(gdf) - len(result)
logger.info(
"deduplicate_points_tolerance: removed %d duplicate(s) from %d input points "
"(tolerance=%.3f m, method=%s)",
removed, len(gdf), tolerance_meters, method,
)
return resultKey implementation notes
estimate_utm_crs()is not a guess. GeoPandas computes the best-fit UTM zone from the dataset’s bounding box centroid, giving sub-metre accuracy for most datasets outside polar regions. This makestolerance_metersa true metric distance rather than an approximation in degrees. Apply CRS normalization across mixed datasets before calling this function when your inputs span multiple CRS authorities.- Path halving vs. path compression. The
find()loop uses path halving (parent[i] = parent[parent[i]]) rather than full two-pass path compression. It achieves nearly identical amortized complexity with a single pass and avoids recursion depth issues on large clusters in CPython. - Transitivity matters.
query_ball_pointfinds direct neighbours within the radius, but duplicates can form chains: A is within tolerance of B, B is within tolerance of C, yet A is outside tolerance of C. Union-find captures the full transitive closure, ensuring all three merge into one cluster — something a simple group-by on spatial grid cells misses. method="centroid"shifts all cluster coordinates. The arithmetic mean of cluster members is not the same as the medoid (the member closest to the mean). For highly elongated clusters — e.g., GPS traces along a straight road — the centroid may fall between two original positions. Usemethod="first"when you need to preserve provenance-linked attributes.- CRS round-trip. The function reprojects to metric, deduplicates, then reprojects back to
original_crs. This prevents silently changing the output CRS — a common source of downstream join failures when the caller expects WGS 84 output. - Logging over silent drop. The
logger.infocall at the end records exactly how many features were removed. Wire this into your pipeline’s observability layer (e.g., Airflow XCom, Prefect task metadata) rather than letting duplicate removal happen silently.
Performance and tolerance selection
| Dataset size | Naïve pdist memory | cKDTree memory | Runtime (typical) |
|---|---|---|---|
| 10,000 pts | ~800 MB | ~12 MB | 0.4 s |
| 100,000 pts | ~80 GB (OOM) | ~120 MB | 2.1 s |
| 1,000,000 pts | N/A | ~1.2 GB | 19 s |
Choosing tolerance_meters:
- Consumer GPS traces:
3.0–5.0m (CEP-50 of most smartphone GPS chips) - RTK survey-grade data:
0.01–0.05m (horizontal precision of dual-frequency receivers) - Digitized historical maps:
1.0–2.0m (paper distortion and scanning artefacts) - LiDAR ground returns:
0.05–0.15m (point density dependent)
Validate your chosen threshold by sampling cluster radii before committing to a production run:
import numpy as np
from scipy.spatial import cKDTree
# Quick threshold diagnostic — run on a sample before processing the full dataset
sample_coords = coords[:5000]
sample_tree = cKDTree(sample_coords)
neighbor_lists_sample = sample_tree.query_ball_point(sample_coords, r=tolerance_meters)
cluster_radii = []
for i, nbrs in enumerate(neighbor_lists_sample):
if len(nbrs) > 1:
dists = np.linalg.norm(sample_coords[nbrs] - sample_coords[i], axis=1)
cluster_radii.append(float(dists.max()))
if cluster_radii:
print(f"Max intra-cluster radius: {max(cluster_radii):.3f} m")
print(f"Median intra-cluster radius: {np.median(cluster_radii):.3f} m")
print(f"Clusters with >1 member: {len(cluster_radii)}")Troubleshooting common failures
| Symptom | Root cause | Fix |
|---|---|---|
AttributeError: 'NoneType' has no attribute 'is_geographic' |
gdf.crs is None — layer loaded without CRS metadata |
Call gdf.set_crs(epsg=4326, inplace=True) before deduplication |
| Output has more rows than expected | Input contains MultiPoint geometries |
Explode with gdf.explode(index_parts=False) first |
| Tolerance behaves as degrees, not metres | CRS was geographic (lat/lon) but is_geographic check was bypassed |
Ensure the CRS check in the function is not skipped; never pass a pre-projected frame and then skip reprojection |
| Centroid falls outside original point cloud | Highly elongated cluster spanning more than 2× tolerance | Use method="first" or reduce tolerance_meters |
Integration note
Insert deduplicate_points_tolerance immediately after ingestion and before any topology-sensitive operation. In a typical cleaning sequence — ingest raw points, validate geometry types, deduplicate, repair geometry, normalize CRS, then join — deduplication must precede geometry repair with Shapely and GeoPandas because some repair operations (like make_valid) produce unexpected results on stacked coincident points. For datasets where coordinate precision and rounding are also issues, run precision normalization first, then deduplicate; this shrinks cluster sizes and speeds up the spatial index queries.
For chunked or tiled processing of large point clouds (>5 M records), apply the function per tile and run a second pass restricted to a buffer strip around tile boundaries to catch cross-tile duplicates introduced by the tiling boundary itself.
Related
- Spatial Deduplication & Topology Simplification — parent guide covering the full deduplication and topology workflow
- Geometry Repair with Shapely and GeoPandas — fixing self-intersections and invalid geometries that often accompany duplicate points
- Fixing Self-Intersecting Polygons in GeoPandas — related topology repair technique using
make_valid - CRS Normalization Across Mixed Datasets — align coordinate reference systems before tolerance-based comparisons
- Handling Precision and Coordinate Rounding — reduce artificial near-duplicates created by floating-point truncation during reprojection