Detecting and Fixing Self-Intersecting Polygons
A self-intersecting polygon — a ring that crosses itself into a bowtie, or doubles back on a spike — is the most common invalid geometry in real spatial data, and it poisons every predicate downstream because operations on it are undefined. This guide sits beneath geometry validation patterns and shows how to detect self-intersections with Shapely 2.x diagnostics, repair them with the right tool for the defect, and gate against them so invalid geometry never enters the pipeline. The engineering subtlety is that “fix it” is not one operation: make_valid and buffer(0) repair different defects in different ways, and choosing wrong either fails to fix the geometry or silently changes its area.
Why self-intersections occur
Self-intersections arise from the digitisation and processing history of the data, not from a single bug. A polygon digitised by hand can have a ring that crosses itself where two vertices were placed out of order. A simplification algorithm run with too coarse a tolerance can collapse a thin neck into a crossing. A union or buffer operation can emit a bowtie at a pinch point. Under the OGC Simple Features model, a valid polygon’s rings must not cross, so any of these produces a geometry that is_valid rejects and that predicates like contains or area evaluate unpredictably.
Diagnostic and repair reference
| Tool | Purpose | Note |
|---|---|---|
is_valid(geom) |
Boolean validity | First gate, cheap |
explain_validity(geom) |
Human-readable reason + location | For debugging the specific defect |
make_valid(geom) |
Structure-preserving repair (GEOS ≥ 3.10) | May return a MultiPolygon or GeometryCollection |
buffer(0) |
Repair via zero-width buffer | Can drop slivers, changes area subtly |
set_precision(geom, grid) |
Snap to a grid before repair | Removes micro self-touches |
Step-by-step implementation
The pattern targets Shapely 2.x and GeoPandas 0.14+, detecting then repairing invalid polygons and asserting the repair worked.
Step 1 — Detect and explain
import geopandas as gpd
from shapely import is_valid
from shapely.validation import explain_validity
gdf = gpd.read_file("data/parcels.gpkg")
invalid = gdf[~gdf.geometry.map(is_valid)]
for idx, geom in invalid.geometry.items():
print(idx, explain_validity(geom)) # e.g. "Self-intersection[500000 5649776]"
Step 2 — Choose the repair by defect
Prefer make_valid, which preserves structure and is explicit about the result type; reserve buffer(0) for simple self-touch rings where a small area change is acceptable.
from shapely import make_valid, set_precision
def repair(geom):
snapped = set_precision(geom, grid_size=1e-6) # kill micro self-touches
return make_valid(snapped) # structure-preserving repair
Step 3 — Verify the repair did not change the polygon’s meaning
A repair that turns one polygon into a MultiPolygon or shifts area by more than a tolerance is a red flag, not a success — bound the area change so a “fix” cannot silently rewrite the geometry.
from shapely import area
def repair_checked(geom, area_tol=1e-3):
fixed = repair(geom)
if area(geom) > 0:
delta = abs(area(fixed) - area(geom)) / area(geom)
assert delta <= area_tol, f"repair changed area by {delta:.2%}"
assert is_valid(fixed)
return fixed
The area bound uses the same relative-delta idea as spatial tolerance thresholds.
Step 4 — Gate against invalid input
def test_no_self_intersections(gdf):
bad = gdf[~gdf.geometry.map(is_valid)]
assert bad.empty, f"{len(bad)} invalid geometries: {list(bad.index)}"
Verification pattern
Confirm the detector catches a known bowtie and the repair validates it, in one runnable check.
from shapely.geometry import Polygon
from shapely import is_valid
bowtie = Polygon([(0, 0), (1, 1), (1, 0), (0, 1), (0, 0)]) # self-intersecting
assert not is_valid(bowtie)
assert is_valid(repair(bowtie)) # repaired to valid
Failure modes and edge cases
buffer(0)dropping area. On a bowtie,buffer(0)can return only one lobe, silently halving the area; prefermake_validand bound the area change.- Repair changes geometry type.
make_validmay return aGeometryCollection; downstream code expecting aPolygonbreaks — assert or coerce the result type. - Micro self-touch below grid. Sub-nanometre self-touches from float noise are best removed with
set_precisionbefore repair, not by a coarse buffer. - Valid but degenerate. A zero-area “polygon” (all collinear vertices) can pass some checks; assert a minimum area where a real polygon is expected.
- Repairing hides the source. Auto-repairing on ingestion masks an upstream digitisation bug; log every repair so the producer can fix the root cause, echoing the security boundaries rule of never silently mutating untrusted input.
Conclusion
Self-intersecting polygons are detected with is_valid and explain_validity, repaired with make_valid (with set_precision for micro-touches), and verified by bounding the area change so a repair cannot rewrite the geometry — all behind a pytest gate that keeps invalid input out. Because these defects make every downstream predicate undefined, this is the first validity gate a suite should run. For the broader validation context, return to geometry validation patterns.