Snapping Tolerances for Topology Cleanup
Measure two things before picking a number: the largest gap you intend to close, and the smallest genuine separation you must preserve. The tolerance goes between them. If those two measurements overlap — if the slivers are as wide as the real gaps — no single tolerance can be correct, and the cleanup needs a different approach entirely.
This page belongs to the Geometry Validity & Topology Repair guide. Where Detecting and Removing Duplicate Vertices is about redundancy within one geometry, this is about gaps between geometries.
Why This Matters
Adjacent parcels that were digitised independently almost never share vertices exactly. The result is a thin sliver of empty space along every shared boundary, a few centimetres wide, invisible at map scale and consequential everywhere else: an area total that omits the slivers, a point-in-polygon test that finds nothing for a point that fell in one, a dissolve that produces holes along every internal boundary.
Snapping closes them by moving nearby vertices onto common positions. The risk is symmetrical: a tolerance large enough to close a five-centimetre sliver is also large enough to merge two features genuinely five centimetres apart, and there is nothing in the geometry to distinguish the two cases. Choosing the number is therefore a data question rather than a technical one, and the technical part — the actual snap — is the easy half.
Prerequisites
- Python 3.9+ with
shapely2.0+ - A projected reference system, so the tolerance is in metres rather than degrees
- A backup: snapping moves coordinates and is not reversible
- Understanding of what validity does and does not cover, from the parent guide
Primary Method
# Measure the two bounds that determine a usable tolerance
from shapely import from_wkb
from shapely.strtree import STRtree
def gap_distribution(geoms, max_distance: float = 1.0) -> list[float]:
"""
Distances between each geometry and its nearest neighbour, up to
max_distance. Zero means they already touch; small positive values
are candidate slivers; larger ones are genuine separations.
"""
tree = STRtree(geoms)
gaps = []
for i, g in enumerate(geoms):
# everything within max_distance, excluding the geometry itself
for j in tree.query(g.buffer(max_distance)):
if j == i:
continue
d = g.distance(geoms[j])
if 0 < d <= max_distance:
gaps.append(d)
return sorted(gaps)
The distribution this produces is the whole decision. A layer with a clean answer shows two clusters: one near zero (the slivers) and one at whatever the real minimum separation is, with an empty band between them. The tolerance belongs in that band, and its width is a measure of how safe the operation is.
Step-by-Step Walkthrough
1. Measure the gap distribution
import sqlite3
from shapely import from_wkb
conn = sqlite3.connect("file:parcels.gpkg?mode=ro", uri=True)
geoms = [from_wkb(gpb_to_wkb(b))
for (b,) in conn.execute("SELECT geom FROM parcels WHERE geom IS NOT NULL")]
gaps = gap_distribution(geoms, max_distance=2.0)
print(f"{len(gaps)} neighbour pairs within 2 m")
for pct in (50, 75, 90, 95, 99):
print(f"p{pct}: {gaps[len(gaps) * pct // 100]:.4f} m")
2. Find the band explicitly
Rather than eyeballing percentiles, look for the widest run of distances with nothing in it. That run is the band, and its bounds are the numbers to reason about.
def widest_empty_band(gaps: list[float]) -> tuple[float, float]:
"""The largest interval between consecutive observed distances."""
best = (0.0, 0.0)
for a, b in zip(gaps, gaps[1:]):
if (b - a) > (best[1] - best[0]):
best = (a, b)
return best
low, high = widest_empty_band(gaps)
print(f"usable band: {low:.4f} m .. {high:.4f} m (width {high - low:.4f} m)")
print(f"suggested tolerance: {(low + high) / 2:.4f} m")
3. Sanity-check the band against the domain
A statistical band is a starting point, not an answer. Two questions decide whether to trust it. Does the lower bound match the capture accuracy — if the slivers are wider than the equipment’s error, they may be real features rather than artefacts. Does the upper bound match something meaningful on the ground — a forty-centimetre minimum separation between parcels is plausible for a boundary wall; a four-metre one probably means the layer contains two different feature classes.
4. Snap, then verify
# Snap a layer to the chosen tolerance, refusing anything that breaks
from shapely import set_precision, to_wkb
TOLERANCE = 0.10 # metres, from the middle of the band
conn = sqlite3.connect("parcels.gpkg", isolation_level=None)
updates, skipped = [], []
for fid, blob in conn.execute("SELECT fid, geom FROM parcels WHERE geom IS NOT NULL"):
geom = from_wkb(gpb_to_wkb(blob))
snapped = set_precision(geom, TOLERANCE)
if snapped.is_empty or not snapped.is_valid:
skipped.append(fid)
continue
if abs(snapped.area - geom.area) / max(geom.area, 1e-12) > 0.01:
skipped.append(fid) # more than 1% of this feature's area moved
continue
updates.append((to_wkb(snapped), fid))
conn.execute("BEGIN IMMEDIATE")
conn.executemany(
"UPDATE parcels SET geom = GeomFromWKB(?, 27700) WHERE fid = ?", updates
)
conn.execute("COMMIT")
print(f"snapped {len(updates)}; skipped {len(skipped)} for review")
The per-feature area guard is what a global area check cannot give you. Across a layer, slivers closing in one place and opening in another largely cancel, so the total barely moves while individual features change substantially. Checking per feature catches exactly the rows a reviewer needs to see.
Verification
Re-measure the gap distribution after the pass. Every distance below the tolerance should be gone, and nothing above it should have moved.
after = gap_distribution(reloaded_geoms, max_distance=2.0)
below = [g for g in after if g < TOLERANCE]
assert not below, f"{len(below)} gaps remain below the tolerance"
# and the upper cluster should be intact
assert min(g for g in after) >= TOLERANCE
-- No feature may have become invalid, and the count must be unchanged
SELECT count(*) AS invalid FROM parcels WHERE ST_IsValid(geom) = 0;
SELECT count(*) AS features FROM parcels;
A dropped feature count is the failure to watch for: a feature that collapsed entirely under the snap disappears, and unlike an invalid geometry, nothing else reports it.
Alternative Approaches or Edge Cases
When the band has zero width. If sliver gaps and genuine separations overlap in size, no tolerance separates them and snapping will damage the layer whatever value you choose. The honest options are to fix the source — re-digitise shared boundaries against a common topology — or to snap only the pairs you can identify as artefacts by some other signal, such as sharing an attribute value that says they are neighbours.
Snapping to a reference layer. Where an authoritative boundary layer exists, snapping the working layer to it rather than to itself is both safer and more meaningful: the tolerance then answers “how far may a vertex have drifted from the authority”, which is a question with a defensible answer. shapely.snap(geom, reference, tolerance) does this per geometry.
Geographic coordinates. A tolerance in degrees is not a distance, and it varies with latitude. Project to a suitable planar system before measuring or snapping — the alternative is an effective tolerance that shrinks toward the poles, which produces a cleanup that works in the south of an extent and not in the north.
Troubleshooting
Features disappear after snapping
Cause: A feature smaller than the tolerance collapsed to nothing. Fix: Guard on is_empty before writing, as the pass does, and compare feature counts afterwards. Small features — survey markers, structures, isolated fragments — are exactly the ones a tolerance chosen from parcel boundaries will destroy.
Gaps remain after the pass
Cause: The two geometries were further apart than the tolerance, or set_precision snapped them to different grid lines. Grid snapping aligns coordinates to a lattice rather than to each other, so two vertices either side of a grid boundary can stay apart. Fix: Use shapely.snap between the specific pair, or increase the grid size — but re-check the band first, because increasing it is what merges real neighbours.
New self-intersections appear
Cause: Two segments of one ring snapped onto the same line. Fix: Repair with the pass in How to Repair Self-Intersecting Polygons with Shapely, and treat a high rate of new invalidity as evidence the tolerance is too coarse.
Frequently Asked Questions
Should snapping run before or after a validity repair?
Validity repair first, snapping second, validity check again. Snapping an already-invalid geometry gives GEOS an ill-defined input and the result is unpredictable; snapping a valid one can create new invalidity, which is why the check repeats. Running the two in the other order tends to produce a layer where each pass undoes some of the other’s work.
Does this fix gaps between layers as well as within one?
Not as written — the measurement and the snap both operate within a single layer. For cross-layer alignment, snap the working layer to the authoritative one with shapely.snap, which moves the working geometry onto the reference’s vertices and leaves the reference untouched. That asymmetry is the point: one layer is the authority and the other is being corrected toward it.
How does this interact with the spatial index?
Every snapped geometry has a new bounding box, so the index must be rebuilt afterwards exactly as with any geometry-changing update. The measurement phase, by contrast, benefits enormously from an index — the STRtree in the primary method is a Shapely-side equivalent, built once in memory, because doing the nearest-neighbour search without one is quadratic in the feature count.
Related
- Geometry Validity & Topology Repair — parent guide: the properties a cleanup must preserve
- Detecting and Removing Duplicate Vertices — the same grid-snapping tool applied within a geometry rather than between them
- How to Repair Self-Intersecting Polygons with Shapely — repairing what an aggressive snap can create
- Datum Transformations & Projection Accuracy — why the tolerance must be measured in a projected system