This guide is part of Handling Precision & Coordinate Rounding, which sits within the Automated Vector & Raster Cleaning Workflows reference for building reliable spatial ETL pipelines.
Snapping Coordinates to a Grid with set_precision
The Problem: Sub-Grid Noise Breeds Near-Duplicate Vertices and Slivers
Coordinates arriving from GPS traces, CAD exports, and reprojected feeds carry more decimal digits than any instrument measured. That excess is noise, and it manifests as vertices that sit fractions of a millimetre apart where they should be identical β which spawns sliver polygons, false near-duplicates, and topology-check failures downstream.
Rounding X and Y independently with Pythonβs round() or numpy.round() makes it worse: each coordinate moves on its own axis, so a shared boundary vertex in one polygon lands on a different rounded value than the same vertex in its neighbour. shapely.set_precision solves this the right way β it snaps all coordinates onto one uniform grid in a single vectorized GEOS call, so vertices that were microscopically apart become the same grid node and collapse.
- Slivers on shared edges: two vertices that should coincide but differ by 1e-9 create a hairline gap that fails
ST_IsValidand inflatessjoincounts. - False near-duplicates: the same node stored at slightly different precision across sources reads as two vertices, bloating geometry size and spatial-index build time.
- The collapse can invalidate a polygon: snapping is not free β pulling two vertices onto one node can pinch a thin neck into a self-touch, which is why revalidation and recovery have to be part of the same routine.
Version and Environment Compatibility
| Shapely | GeoPandas | GEOS | Recommended path | Caveat |
|---|---|---|---|---|
| β₯ 2.0 | β₯ 0.13 | β₯ 3.9 | shapely.set_precision(arr, grid) + make_valid recovery |
Preferred; mode='valid_output' keeps most outputs valid |
| 2.0.x | 0.12 | β₯ 3.9 | set_precision(gdf.geometry.values, grid) |
Extract .values explicitly when assigning back |
| < 2.0 | < 0.12 | < 3.8 | Manual snap() per geometry |
No vectorized array API; slow and topology not guaranteed |
Install the recommended stack:
pip install "geopandas>=0.13.0" "shapely>=2.0.0" numpy pyprojSnap, Then Recover
The mode parameter controls how set_precision reacts to the topological conflicts a collapse can create. The default "valid_output" reorganises rings to keep outputs valid where it can; "pointwise" snaps coordinates with no topological reasoning and readily produces self-intersections. Keep the default, and treat make_valid as the safety net for the residual cases the reorganiser cannot resolve on its own.
Recipe: snap_to_grid with Invalid Recovery
The function snaps the whole geometry array with set_precision, detects any post-snap invalids, repairs them with make_valid, drops whatever remains unrecoverable, and returns a metrics dict. Unlike a routine that merely warns, this one hands back a fully valid frame.
import logging
import geopandas as gpd
import numpy as np
import shapely
logger = logging.getLogger(__name__)
def snap_to_grid(
gdf: gpd.GeoDataFrame,
grid_size: float,
recover_invalid: bool = True,
) -> tuple[gpd.GeoDataFrame, dict]:
"""
Snap every vertex to a fixed grid with shapely.set_precision, then recover
any geometry the grid collapse rendered invalid via shapely.make_valid.
Grid snapping is the correct way to remove near-duplicate vertices and the
slivers they create: set_precision moves all coordinates onto one lattice in
a single vectorized GEOS call, so microscopically separated vertices become
identical and collapse. The trade-off is that pulling two vertices onto the
same node can pinch a thin polygon into a self-touch, which is why post-snap
revalidation and recovery live in the same routine.
Parameters
----------
gdf : GeoDataFrame
Layer in a projected (metric) CRS.
grid_size : float
Grid spacing in CRS units. 0.001 = 1 mm in a metric CRS.
recover_invalid : bool
When True, repair post-snap invalids with make_valid instead of
dropping them.
Returns
-------
(GeoDataFrame, dict)
A snapped, fully valid frame plus a metrics dict.
"""
if gdf.crs is None or gdf.crs.is_geographic:
raise ValueError(
"set_precision snaps in CRS units; project to a metric CRS first so "
"grid_size is a real ground distance, not degrees."
)
geom = gdf.geometry.values # GeometryArray β vectorized Shapely 2.0 input
verts_before = int(shapely.get_num_coordinates(geom).sum())
# Single vectorized GEOS call. Default mode='valid_output' reorganises
# topology to keep outputs valid where it can.
snapped = shapely.set_precision(geom, grid_size=grid_size)
verts_after = int(shapely.get_num_coordinates(snapped).sum())
invalid = ~shapely.is_valid(snapped)
n_invalid = int(invalid.sum())
recovered = 0
if n_invalid and recover_invalid:
snapped = np.where(invalid, shapely.make_valid(snapped), snapped)
recovered = n_invalid - int((~shapely.is_valid(snapped)).sum())
valid_mask = shapely.is_valid(snapped)
dropped = int((~valid_mask).sum())
if dropped:
logger.warning("%d geometries remain invalid after snap β dropping", dropped)
result = gdf.copy()
result.geometry = snapped
result = result[valid_mask].reset_index(drop=True)
metrics = {
"features_in": len(gdf),
"features_out": len(result),
"grid_size": grid_size,
"vertices_before": verts_before,
"vertices_after": verts_after,
"vertices_removed": verts_before - verts_after,
"invalid_after_snap": n_invalid,
"recovered_with_make_valid": recovered,
"dropped": dropped,
}
logger.info("snap_to_grid: %s", metrics)
return result, metricsKey Implementation Notes
- Vertex reduction is the observable signal.
vertices_before - vertices_afterquantifies how much near-duplicate noise the grid removed. A count near zero meansgrid_sizeis finer than the noise and is doing nothing; a large drop confirms slivers were collapsed. Logging it makes the operation auditable rather than a silent transform. make_validruns only on the invalid subset. Thenp.where(invalid, ...)guard repairs just the geometries that broke, leaving valid outputs untouched. Repairing everything unconditionally wastes GEOS calls and can reorder rings on geometries that never needed it.recoveredanddroppedare distinct outcomes. A geometry can be invalid after snapping, recovered bymake_valid, and end up valid β or resist repair and be dropped. Reporting both separately tells you whethergrid_sizeis merely aggressive (highrecovered) or genuinely destructive (highdropped).- Keep
mode='valid_output'. The default already reorganises topology to preserve validity. Switching to"pointwise"snaps coordinates with no topological reasoning, producing far more work for the recovery path and occasionally geometriesmake_validcannot fix. - Snapping precedes deduplication. Collapsing near-duplicate vertices first shrinks cluster sizes and speeds the spatial index used later, which is why this step runs before spatial deduplication and topology simplification. If inputs are already invalid before snapping, resolve them with geometry repair with Shapely and GeoPandas first.
Troubleshooting Grid Snapping
| Symptom | Root Cause | Fix |
|---|---|---|
TopologyException / invalid after set_precision |
Grid pulled a thin neck into a self-touch | Keep recover_invalid=True so make_valid repairs it, or reduce grid_size |
MultiPolygon became Polygon after snapping |
Component parts collapsed onto a shared node | Accept the type change, or reduce grid_size when the schema is strict |
| Near-duplicate vertices survive snapping | grid_size smaller than the vertex spacing |
Increase grid_size to exceed the noise amplitude |
grid_size behaves as ~100 m, not 1 mm |
Snapping ran in EPSG:4326 degrees | Project to a metric CRS before calling set_precision |
| Output changes on a second run | Recovery applied to already-snapped inputs | Snap once upstream; set_precision is idempotent for an equal grid_size |
Integration Note
Place snap_to_grid in the Transform stage after CRS normalisation and before deduplication, so downstream comparisons see coordinates already on a common lattice. It feeds the workflow in handling precision and coordinate rounding and pairs closely with reducing coordinate precision to shrink GeoJSON: snap in a metric CRS for topology correctness during processing, then trim decimal precision in degrees only at the GeoJSON export boundary. Emit the metrics dict to your orchestrator so a spike in dropped flags a grid_size that has grown too coarse for the layer.
Related
- Handling Precision and Coordinate Rounding β the full precision-normalisation workflow this recipe plugs into
- Reducing Coordinate Precision to Shrink GeoJSON β trim decimal places at the export boundary once geometry is snapped
- Geometry Repair with Shapely & GeoPandas β repair invalid inputs before snapping and unrecoverable outputs after
- Spatial Deduplication & Topology Simplification β the deduplication stage that follows a clean grid snap