Topology in GIS Explained: Slivers, Gaps and Shared Boundaries
Problem statement
Two parcels share a boundary on the map. In the data they share nothing at all:
>>> a.geometry.iloc[0].intersection(b.geometry.iloc[0]).area
0.0034
Three thousandths of a square metre of overlap, invisible at any zoom, produced because two surveyors digitised the same fence line and their coordinates differ in the fourth decimal place. Overlay that layer with another and the result is peppered with sliver polygons; dissolve it and hairline gaps appear; count the area and it does not add up to the whole.
This is a topology problem, and it is distinct from a geometry problem. Each individual polygon is perfectly valid. What is wrong is the relationship between them β and no amount of make_valid() will fix it, because nothing about a single feature is broken.
Quick answer
Topology is about relationships between features, not about individual shapes:
- Geometry is coordinates; topology is adjacency, connectivity and containment
- Simple Features stores geometry only β shared boundaries are coincidental, not enforced
- Slivers and gaps appear when two versions of the same line disagree by a fraction of a unit
- Fix them by snapping to a shared precision grid, then repairing, then dissolving
- Detect them before they cost you: overlap area, gap area, and dangling nodes are all measurable
import geopandas as gpd
from shapely import set_precision
parcels = gpd.read_file("data/raw/parcels.gpkg")
metric = parcels.to_crs(parcels.estimate_utm_crs())
# how much do neighbours overlap, and how much space is unaccounted for?
pairs = gpd.sjoin(metric, metric, predicate="overlaps")
pairs = pairs[pairs.index < pairs["index_right"]]
overlap_area = sum(
metric.geometry.iloc[a].intersection(metric.geometry.iloc[b]).area
for a, b in zip(pairs.index, pairs["index_right"])
)
print(f"{len(pairs)} overlapping pairs, {overlap_area:.2f} mΒ² of double-counted land")
# snapping to a 1 cm grid usually collapses the disagreement entirely
snapped = metric.copy()
snapped["geometry"] = set_precision(metric.geometry.values, grid_size=0.01)
snapped["geometry"] = snapped.geometry.make_valid()
The order matters: snap first so coincident lines become identical, then repair, then do the overlay or dissolve that was failing.
Geometry versus topology
Step-by-step solution
What topology means, precisely
Topology is the set of properties preserved under continuous deformation β which sounds abstract until you list what it means in GIS terms:
- Adjacency β these two parcels share a boundary
- Connectivity β this road segment connects to that one at a node
- Containment β this building is inside this parcel
- Order β these vertices run along the line in this direction
None of those are stored by Simple Features. A GeoPackage holds a list of coordinates per feature and nothing else, so "shares a boundary" is something you discover by comparing coordinates, not something the format guarantees. That is the fundamental trade-off: the simplicity that makes Simple Features universal is exactly what makes topology fragile.
from shapely.geometry import Polygon
a = Polygon([(0, 0), (10, 0), (10, 10), (0, 10)])
b = Polygon([(10, 0), (20, 0), (20, 10), (10, 10)]) # exactly coincident edge
c = Polygon([(10.0001, 0), (20, 0), (20, 10), (10.0001, 10)]) # 0.1 mm apart
print(a.touches(b), a.intersection(b).area) # True 0.0 β perfect adjacency
print(a.touches(c), a.intersection(c).area) # False 0.0 β a hairline gap
The two cases differ by 0.1 mm and are completely different topologically: one is adjacency, the other is a gap.
Slivers: two lines that should be one
import geopandas as gpd
def find_slivers(gdf, max_area_m2=1.0, min_thinness=0.2):
"""Sliver = small area with a very low thinness ratio (long, thin, useless)."""
metric = gdf.to_crs(gdf.estimate_utm_crs())
area = metric.geometry.area
perimeter = metric.geometry.length
thinness = (4 * 3.141592653589793 * area) / (perimeter ** 2) # 1.0 = circle
slivers = (area < max_area_m2) & (thinness < min_thinness)
print(f"{slivers.sum()} sliver polygons, {area[slivers].sum():.2f} mΒ² total")
return gdf.loc[slivers]
A sliver is not defined by size alone β a small square parcel is legitimate. The tell is shape: a very low ratio of area to perimeter means a long, thin fragment, which almost never occurs naturally in cadastral or administrative data.
Gaps: space that belongs to nobody
import geopandas as gpd
from shapely.ops import unary_union
def find_gaps(gdf, tolerance_m=0.05):
metric = gdf.to_crs(gdf.estimate_utm_crs())
dissolved = unary_union(metric.geometry.values)
# closing then opening by a small distance leaves only the hairline gaps
closed = dissolved.buffer(tolerance_m).buffer(-tolerance_m)
gaps = closed.difference(dissolved)
pieces = list(getattr(gaps, "geoms", [gaps])) if not gaps.is_empty else []
print(f"{len(pieces)} gap polygons, {gaps.area:.3f} mΒ² total")
return gpd.GeoDataFrame(geometry=pieces, crs=metric.crs)
The buffer-out-then-in trick (a morphological closing) is the standard way to find gaps narrower than a tolerance: real courtyards and roads survive it; hairline cracks do not.
Overshoots, undershoots and dangles: the line-network versions
import geopandas as gpd
from shapely.ops import unary_union
def find_dangles(lines: gpd.GeoDataFrame, tolerance_m=0.5):
"""End points that do not meet another line β undershoots and overshoots."""
metric = lines.to_crs(lines.estimate_utm_crs())
endpoints = []
for geom in metric.geometry:
for part in getattr(geom, "geoms", [geom]):
coords = list(part.coords)
endpoints += [Point(coords[0]), Point(coords[-1])]
endpoints = gpd.GeoDataFrame(geometry=endpoints, crs=metric.crs)
network = unary_union(metric.geometry.values)
# a genuine node is touched by more than one line; a dangle is not
dangles = endpoints[endpoints.geometry.apply(
lambda p: sum(p.dwithin(g, tolerance_m) for g in metric.geometry) < 2
)]
print(f"{len(dangles)} dangling end points")
return dangles
In a road or river network, a dangle is either a genuine cul-de-sac or a digitising error where a line stopped just short of (undershoot) or just past (overshoot) its neighbour. Only local knowledge separates the two, which is why the output is a review layer, not a deletion.
The fix: snap to a shared precision grid
import geopandas as gpd
from shapely import set_precision
def snap_layer(gdf, grid_size=0.01):
"""Round every coordinate to a shared grid, then repair what that breaks."""
metric = gdf.to_crs(gdf.estimate_utm_crs())
before = metric.geometry.area.sum()
out = metric.copy()
out["geometry"] = set_precision(metric.geometry.values, grid_size=grid_size)
out["geometry"] = out.geometry.make_valid()
out = out[out.geometry.notna() & ~out.geometry.is_empty]
after = out.geometry.area.sum()
print(f"grid {grid_size} m: {len(gdf)} β {len(out)} features, "
f"area change {(after - before) / before:+.4%}")
return out.to_crs(gdf.crs)
set_precision is the important function here. It does more than round: it re-nodes the geometry after snapping, so two edges that land on the same grid points become genuinely identical rather than merely close. Choose a grid coarser than the noise and finer than the smallest real feature β 1 cm suits most cadastral data.
Rebuilding a clean coverage
import geopandas as gpd
def rebuild_coverage(gdf, grid_size=0.01):
snapped = snap_layer(gdf, grid_size)
# union then re-attribute: the geometry becomes topologically clean,
# and each piece takes the attributes of the parcel that contains it
from shapely.ops import unary_union
dissolved = unary_union(snapped.geometry.values)
pieces = gpd.GeoDataFrame(
geometry=list(getattr(dissolved, "geoms", [dissolved])), crs=snapped.crs
)
pieces["rep"] = pieces.representative_point()
rebuilt = gpd.sjoin(pieces.set_geometry("rep"), snapped, predicate="within")
return rebuilt.set_geometry("geometry")
For serious coverage work, GRASS (via QGIS Processing) and PostGIS's ST_Node / topology extension maintain real topology rather than reconstructing it. Python's Simple Features stack can clean a coverage; it cannot keep one.
Detect before you overlay
def topology_report(gdf) -> dict:
metric = gdf.to_crs(gdf.estimate_utm_crs())
pairs = gpd.sjoin(metric, metric, predicate="overlaps")
pairs = pairs[pairs.index < pairs["index_right"]]
overlap = sum(
metric.geometry.iloc[a].intersection(metric.geometry.iloc[b]).area
for a, b in zip(pairs.index, pairs["index_right"])
)
gaps = find_gaps(gdf)
return {
"features": len(gdf),
"invalid": int((~gdf.geometry.is_valid).sum()),
"overlapping_pairs": len(pairs),
"overlap_area_m2": round(overlap, 3),
"gap_pieces": len(gaps),
"gap_area_m2": round(float(gaps.geometry.area.sum()) if len(gaps) else 0.0, 3),
}
Running this before an overlay tells you whether to expect slivers in the output β and gives you a number to quote when you send the data back to its supplier.
Code examples
Example 1: a full clean-up for a parcel coverage
import geopandas as gpd
from shapely import set_precision
def clean_coverage(path, grid_size=0.01, min_sliver_m2=1.0):
gdf = gpd.read_file(path)
metric = gdf.to_crs(gdf.estimate_utm_crs())
report = {"input": len(gdf), "area_before_m2": round(float(metric.area.sum()), 2)}
# 1. snap coincident lines onto a shared grid
metric["geometry"] = set_precision(metric.geometry.values, grid_size=grid_size)
# 2. repair what snapping broke
metric["geometry"] = metric.geometry.make_valid()
metric = metric[metric.geometry.notna() & ~metric.geometry.is_empty]
metric = metric.explode(index_parts=False, ignore_index=True)
metric = metric[metric.geom_type.isin(["Polygon", "MultiPolygon"])]
# 3. drop the fragments that snapping left behind
thinness = (4 * 3.14159 * metric.geometry.area) / (metric.geometry.length ** 2)
slivers = (metric.geometry.area < min_sliver_m2) & (thinness < 0.2)
report["slivers_dropped"] = int(slivers.sum())
metric = metric.loc[~slivers]
report.update({
"output": len(metric),
"area_after_m2": round(float(metric.geometry.area.sum()), 2),
"invalid_after": int((~metric.geometry.is_valid).sum()),
})
report["area_change_pct"] = round(
(report["area_after_m2"] / report["area_before_m2"] - 1) * 100, 4)
return metric.to_crs(gdf.crs), report
clean, report = clean_coverage("data/raw/parcels.gpkg")
for k, v in report.items():
print(f"{k:18} {v}")
Example 2: prove that snapping worked
import geopandas as gpd
def adjacency_quality(gdf) -> dict:
metric = gdf.to_crs(gdf.estimate_utm_crs())
touching = gpd.sjoin(metric, metric, predicate="touches")
overlapping = gpd.sjoin(metric, metric, predicate="overlaps")
for frame in (touching, overlapping):
frame.drop(frame.index[frame.index == frame["index_right"]], inplace=True)
return {
"clean adjacencies (touches)": len(touching) // 2,
"dirty adjacencies (overlaps)": len(overlapping) // 2,
}
print("before:", adjacency_quality(gpd.read_file("data/raw/parcels.gpkg")))
print("after :", adjacency_quality(clean))
A successful snap converts overlaps relationships into touches relationships. That is the single clearest measure that topology has improved.
Example 3: keep shared boundaries when simplifying
import geopandas as gpd
import topojson as tp
gdf = gpd.read_file("data/raw/districts.gpkg").to_crs(3857)
# independent simplification tears the coverage apart
naive = gdf.copy()
naive["geometry"] = gdf.geometry.simplify(25, preserve_topology=True)
# topology-aware simplification simplifies each shared arc once
topo = tp.Topology(gdf, prequantize=False)
shared = topo.toposimplify(25).to_gdf()
shared.crs = gdf.crs
for name, layer in (("naive", naive), ("topology-aware", shared)):
pairs = gpd.sjoin(layer, layer, predicate="overlaps")
pairs = pairs[pairs.index < pairs["index_right"]]
print(f"{name:16} {len(pairs)} overlapping pairs after simplification")
This is the clearest everyday demonstration of why topology matters: the same tolerance produces a clean coverage or a mess of slivers depending on whether shared arcs are simplified once or twice.
Example 4: check a road network's connectivity
import geopandas as gpd
import networkx as nx
from shapely.geometry import Point
roads = gpd.read_file("data/raw/roads.gpkg").to_crs(27700)
graph = nx.Graph()
for idx, line in zip(roads.index, roads.geometry):
for part in getattr(line, "geoms", [line]):
coords = list(part.coords)
start, end = tuple(round(c, 2) for c in coords[0]), tuple(round(c, 2) for c in coords[-1])
graph.add_edge(start, end, index=idx, length=part.length)
components = list(nx.connected_components(graph))
print(f"{graph.number_of_nodes()} nodes, {graph.number_of_edges()} edges")
print(f"{len(components)} disconnected components "
f"(largest holds {max(len(c) for c in components)} nodes)")
dangles = [n for n, d in graph.degree() if d == 1]
print(f"{len(dangles)} dangling nodes")
Rounding the endpoints to 2 decimal places is the snapping step β without it, near-coincident endpoints become separate graph nodes and the network shatters into hundreds of components.
Explanation
There are two ways to store spatial data, and GIS has spent forty years alternating between them. In a topological model β ArcInfo coverages, GRASS vectors, PostGIS topology, TopoJSON β the boundary between two parcels is stored once, as an arc, and both parcels reference it. In a simple features model β shapefiles, GeoPackage, GeoJSON, Shapely β each polygon carries its own complete ring, and the shared boundary exists twice.
Simple Features won because it is simple: every feature is independent, files are easy to write, and no shared structure has to be maintained when one polygon is edited. The cost is precisely the problem in this article. Two copies of "the same" boundary are only equal if every coordinate matches exactly, and floating-point coordinates produced by different processes essentially never do. A reprojection, a simplification, an export through a format with different precision β any of these can move one copy by a nanometre and turn adjacency into a sliver.
Understanding this explains why the fix is snapping rather than repairing. make_valid() operates on one geometry at a time; there is nothing invalid about either parcel. What is needed is an operation that makes the two boundaries identical, and rounding all coordinates to a shared grid is exactly that: after snapping to 1 cm, two lines that were 0.3 mm apart land on the same grid points and become the same line. The re-noding that set_precision performs afterwards is what makes the result geometrically consistent.
The same reasoning explains the simplification trap. DouglasβPeucker chooses which vertices to keep based on the geometry it is given, so two copies of a shared boundary in two different polygons get two different simplifications. Topology-aware tools decompose the coverage into arcs, simplify each arc once, and rebuild β which is why topojson.toposimplify() produces a clean coverage where GeoSeries.simplify() produces slivers.
Finally, know when to leave Python. Cleaning a coverage once is straightforward with snapping and repair. Maintaining topology β so that editing one parcel automatically updates its neighbour β requires a topological data store: PostGIS topology, GRASS, or a QGIS project with topological editing enabled. If your workflow edits boundaries repeatedly, that is the right tool, and the Simple Features stack is the wrong one.
Edge cases or notes
- Snapping changes area: Usually by a tiny fraction, but check and report it. A large change means the grid is too coarse.
set_precisioncan produce empty geometries: Very thin slivers collapse entirely. Filter foris_emptyafterwards.- Not every gap is an error: Roads, rivers and courtyards are legitimate holes in a parcel coverage. Use a tolerance, and review.
- Not every dangle is an error either: Cul-de-sacs are real. Dangle detection produces a review layer, not a delete list.
touchesis exact: Two polygons 0.1 mm apart do not touch. Usedwithinwith a tolerance when testing adjacency on unsnapped data.- Coverage validation is O(nΒ²) without an index: Always use
sjoin, which uses the spatial index, rather than comparing every pair. - GEOS 3.12+ has coverage functions:
coverage_union,coverage_simplifyand validity checks for coverages are newer and worth using where available.
Internal links
- How to Snap and Align Geometries to Fix Slivers and Gaps in Python
- What Makes a Geometry Valid? The OGC Rules Explained
- Shapely TopologyException: Found Non-Noded Intersection (How to Fix)
- Overlay Operations in GeoPandas: Union, Intersection, Difference Explained
- How to Simplify Geometry in Python with GeoPandas and Shapely
- Coordinate Precision and Floating Point in GIS Explained
FAQ
What is the difference between geometry and topology?
Geometry is the coordinates of a feature. Topology is the relationships between features β adjacency, connectivity, containment. Simple Features formats store the first and leave the second to be inferred.
Why do slivers appear when I overlay two layers?
Because the shared boundaries in the two layers were digitised or processed separately and differ by a fraction of a unit. The overlay faithfully reports those differences as tiny polygons.
How do I get rid of slivers?
Snap both layers to a shared precision grid with shapely.set_precision(), repair with make_valid(), then overlay. Remove any remaining fragments by area and thinness, not by area alone.
Does make_valid() fix topology problems?
No. It repairs individual geometries. A sliver between two valid polygons is a relationship problem, and needs snapping instead.
Why does simplifying a coverage create gaps?
simplify() treats each polygon independently, so a shared boundary is simplified twice, differently. Use a topology-aware simplifier such as topojson.toposimplify().
Can I maintain real topology in Python?
Not with Simple Features alone. Clean it, yes. To maintain it β so an edit to one parcel updates its neighbour β use PostGIS topology, GRASS, or QGIS topological editing.
What grid size should I snap to?
Coarser than the coordinate noise, finer than the smallest real feature. For cadastral data in metres, 1 cm (grid_size=0.01) is a good default; check the area change afterwards.