Snapping Slivers Between Adjacent Parcels
Two agencies digitising the same boundary independently produce a thin overlap along its whole length, a thin gap, or alternating both. Neither polygon is invalid, so is_valid never sees it — and every parcel along that line now has an ambiguous district assignment and an area that depends on which layer you believe. This guide detects those pairwise defects and closes them by snapping to an authoritative source, within a tolerance derived from the fabric’s survey accuracy. It is the pairwise half of geometry validation and topology repair.
Prerequisites
Step-by-step
Step 1: Detect overlaps between features that should only touch
Adjacent parcels should share a boundary and no interior. Any intersection with positive area is a sliver, and the area distribution tells you whether it is noise or a real disagreement.
import geopandas as gpd
def find_overlaps(gdf, id_col="parcel_id", max_sliver_sqft=50.0):
"""Positive-area intersections between neighbours: noise below the threshold,
a genuine boundary dispute above it."""
pairs = gdf.sjoin(gdf[[id_col, "geometry"]], predicate="intersects")
pairs = pairs[pairs[id_col + "_left"] < pairs[id_col + "_right"]] # each pair once
inter = pairs.geometry.intersection(
gdf.set_index(id_col).loc[pairs[id_col + "_right"], "geometry"].values)
out = pairs.assign(sliver_area=inter.area)
return out[out["sliver_area"] > 0].sort_values("sliver_area", ascending=False)
Reading the distribution is the point. Hundreds of slivers of a few square inches are digitising noise; a dozen of several hundred square feet are places where two records genuinely disagree about where a line is, and no snapping tolerance should close those silently.
Step 2: Detect gaps the same way
Gaps are the mirror image and are missed by overlap detection entirely. Union the layer and look for interior holes below the sliver threshold.
from shapely.ops import unary_union
def find_gaps(gdf, max_gap_sqft=50.0):
"""Holes inside the unioned fabric: slivers of nothing between neighbours."""
dissolved = unary_union(gdf.geometry.values)
holes = []
for poly in getattr(dissolved, "geoms", [dissolved]):
for ring in poly.interiors:
from shapely.geometry import Polygon
hole = Polygon(ring)
if hole.area <= max_gap_sqft:
holes.append(hole)
return gpd.GeoDataFrame(geometry=holes, crs=gdf.crs)
A fabric with many small holes usually has one cause — a layer that was generalised, or a snapping pass applied to one source and not its neighbour — and finding the cause is worth more than closing the holes.
Step 3: Choose the tolerance from the data, not from taste
The snapping tolerance decides which disagreements get closed and which get reported, so it should come from the survey accuracy of the fabric rather than from whatever makes the sliver count go to zero.
SURVEY_ACCURACY_FT = 0.4 # from the county's published parcel metadata
SNAP_TOLERANCE_FT = SURVEY_ACCURACY_FT # close what survey error explains, no more
A tolerance at or just below the survey accuracy closes exactly the disagreements that measurement error can account for. A tolerance well above it starts moving boundaries by amounts a rule can notice, which converts a data problem into a compliance problem.
Step 4: Snap to the authoritative layer
Snapping is directional. Moving both layers towards each other produces a boundary neither agency recognises; moving the non-authoritative layer onto the authoritative one produces a boundary one of them will stand behind.
from shapely.ops import snap
def snap_to_authority(subject: gpd.GeoDataFrame, authority: gpd.GeoDataFrame,
tolerance: float) -> gpd.GeoDataFrame:
"""Move the subject layer's vertices onto the authoritative boundary, one-way."""
out = subject.copy()
tree = authority.sindex
snapped = []
for geom in out.geometry:
near = authority.iloc[list(tree.query(geom.buffer(tolerance)))]
target = unary_union(near.geometry.values) if len(near) else None
snapped.append(snap(geom, target, tolerance) if target is not None else geom)
out["geometry"] = snapped
return out
Record which layer was authoritative and what tolerance was used, in the run manifest. A boundary that moved is a change to the evidence, and the reason it moved has to be recoverable.
Step 5: Re-check, and report what did not close
Snapping can introduce new invalidity, and it will not close a real disagreement. Both need checking.
after = snap_to_authority(districts, parcels, SNAP_TOLERANCE_FT)
after["geometry"] = after.geometry.map(lambda g: g if g.is_valid else make_valid(g))
remaining = find_overlaps(gpd.pd.concat([after, parcels]))
report = remaining[remaining["sliver_area"] > 1.0]
print(f"{len(report)} disagreements remain above 1 sq ft — these are not noise")
Verification
Check that snapping did what it was supposed to and nothing else. Sliver count should fall sharply, total area should barely move, and no parcel should have shifted by more than the tolerance.
moved = after.geometry.hausdorff_distance(districts.geometry)
assert moved.max() <= SNAP_TOLERANCE_FT * 1.01, "a boundary moved further than tolerance"
area_change = (after.geometry.area.sum() - districts.geometry.area.sum())
print(f"slivers: {len(remaining)} (was {len(before_report)}); "
f"net area change {area_change:+.1f} sq ft")
The Hausdorff check is the important one: it proves no vertex travelled further than the tolerance permitted, which is the guarantee that makes the operation defensible.
Common Pitfalls
- Snapping both layers towards each other. The result matches neither agency’s record, and neither will accept it.
- Choosing the tolerance by tuning until slivers disappear. That guarantees the tolerance exceeds the real disagreements, which are then closed silently.
- Snapping before repairing validity. An invalid ring snaps unpredictably. Repair first, then snap.
- Treating gaps and overlaps as one problem. They have different causes and different detection queries, and a pass that only looks for overlaps will report a clean fabric full of holes.
Frequently Asked Questions
Which layer should be authoritative?
The one whose record the disagreement will ultimately be resolved against — usually the assessor’s fabric for parcels and the adopting agency’s layer for districts. Where that is genuinely unclear, it is a question for the agencies rather than a default for the pipeline.
What if the two layers disagree by more than survey error?
Then it is a boundary discrepancy, not a sliver, and closing it is a decision the pipeline should not make. Report it with both geometries and its area; these are usually few enough to review individually and are exactly the parcels most likely to be contested.
Does snapping change parcel areas enough to matter?
Within a tolerance derived from survey accuracy, no — the area change is a fraction of a percent on the affected boundary. The Hausdorff and area checks above are what turn that from an expectation into a verified property.
Should this run on every refresh?
Yes, and the sliver count is worth tracking across refreshes. A stable count is routine; a jump means one of the sources changed how it digitises, which is worth knowing before it reaches a verdict.
How expensive is this on a county fabric?
The detection queries are indexed joins and run in seconds over a few hundred thousand parcels; the snapping pass is proportional to the number of features near a shared boundary, which is a small fraction of the whole. The expensive version is the naive one that compares every parcel against every other — the same quadratic trap that spatial indexing exists to remove, and worth checking for if the pass takes more than a minute.
Can snapping be skipped if the slivers are tiny?
Often, yes — and that is a legitimate decision provided it is stated. Slivers well below the area threshold of any rule change nothing, and filtering them out of overlay output is cheaper than moving boundaries to eliminate them. What is not legitimate is leaving them unmeasured: the sliver report is worth producing even when the decision is to do nothing about it, because its trend across refreshes is the early warning that a source has changed.
Does this need re-running after every parcel refresh?
Yes, and it is cheap enough that there is no reason not to. What changes between refreshes is usually small — a handful of parcels re-digitised, a boundary corrected — but a snapping pass is only valid for the geometry it ran against, and skipping it after a refresh leaves the working store in a state where some boundaries are aligned and others are not. Running it as part of ingest, immediately after validity repair, keeps that from becoming a question anybody has to think about — and it means the sliver report arrives as a routine artefact of every refresh rather than as something a particular person has to remember to produce by hand.
Related
Part of: Geometry validation and topology repair
- Fixing invalid parcel polygons with make_valid — the per-geometry repair that runs first.
- Handling edge cases in parcel boundary alignment — the wider alignment pipeline.
- Jurisdictional boundary and precedence resolution — when the disagreement is between agencies rather than layers.
- Classifying mixed-use overlaps with GeoPandas overlay — where uncleaned slivers surface downstream.