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.

Two queries that find what validity missesPositive-area intersections between neighbours find overlaps; interior holes in the unioned fabric find gaps. Neither geometry is invalid.Indexed self-joinneighbour pairsPositive-areaintersectionsoverlapsUnion the fabricinterior ringsSliver reportby area, not by count
Overlap detection alone reports a clean fabric that is full of holes.

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.

Is this disagreement explainable by survey error?A tolerance at or below the fabric’s survey accuracy closes what measurement error accounts for; anything larger is a boundary discrepancy for a person.Is the sliver smaller thanthe fabric’s stated surveyaccuracy?beyond accuracyReport it with both geometriesa boundary discrepancy is not a snapping problemwithin accuracySnap it to the authoritative layerone-way; record the tolerance usedRe-validate afterwards — snapping can create invalidity
Tuning the tolerance until the sliver count reaches zero guarantees it exceeds the real disagreements.

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")
What to verify after a snapping passNo vertex moved further than the tolerance, total area barely changed, sliver count fell sharply, and what remains is reported rather than hidden.Max vertex movement ≤ toleranceHausdorff distance, assertedNet area change negligiblea fraction of a percent on affected boundariesGeometry still validsnapping can introduce new defectsRemaining disagreements reportedthese are the contested boundaries
The Hausdorff check is the one that makes the operation defensible: it proves nothing travelled further than permitted.

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.

Part of: Geometry validation and topology repair