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.
Four shapes that all report as “self-intersection”
GEOS uses one message for several distinct geometric situations, and the repair that is right for one is wrong for another. Recognising the shape from the reported intersection point is what turns a generic make_valid call into a deliberate fix.
The distinction that matters most in production is between the first two. A bowtie is a real geometry error: someone digitised or generalised the ring into crossing itself, and repairing it produces two polygons whose combined area differs from the original. A spike is a degenerate artefact, usually left by an overlay, and removing it changes the area by essentially nothing. Repairing both with the same call is fine; reporting both with the same severity is not, because the first indicates upstream data loss and the second indicates ordinary floating-point residue.
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)}"
Choosing between the two repair methods
Shapely 2.x exposes make_valid with two strategies, and they are not interchangeable. Understanding what each does to a bowtie is the whole decision.
The linework method decomposes the ring into its constituent segments, re-noding at every intersection, and reassembles whatever valid polygons that linework can form. Given a bowtie it returns a MultiPolygon of two triangles — total area is preserved, and no vertex moves. The structure method instead treats the ring as describing an area and rebuilds the boundary of that area, which can dissolve one lobe entirely and return a single polygon of roughly half the original area.
method="linework" |
method="structure" |
|
|---|---|---|
| Vertices | Preserved, plus intersection nodes | May be added and removed freely |
| Bowtie result | MultiPolygon, both lobes kept |
Often a single polygon, one lobe lost |
| Area behaviour | Total area preserved | Area can change substantially |
| Output type | Frequently changes to multi-part | More often stays single-part |
| Right when | The input is data you must not lose | You need a clean single area and the defect is degenerate |
The default should be linework, precisely because it is conservative: it never silently deletes area, so a repair that goes wrong shows up as an unexpected MultiPolygon rather than as quietly missing data. Structure is the right choice only when the downstream schema forbids multi-part geometry and the defect has been confirmed degenerate — a spike or a hairline self-touch, where the dissolved region is numerically insignificant.
Whichever method runs, the repair must be measured rather than trusted. Record the area ratio and the Hausdorff distance between the original and repaired geometry, compare both against the budget, and let anything outside it fail into quarantine with the feature identifier attached. A repair pipeline without that measurement will eventually accept a structure repair that halved a parcel, and nothing downstream will notice until a total is questioned.
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
Where the defect entered, and why that decides the fix
Repairing at the gate is triage, not treatment. A dataset that produces self-intersections every run has a defect further upstream, and the same four stages account for nearly all of them. Locating the stage tells you whether the right response is a repair, a parameter change, or a conversation with a producer.
That last observation is worth acting on operationally: record the invalid-geometry count as a metric per pipeline run, not just as a pass or fail. A step change in the count that lines up with a deployment points squarely at a generalisation tolerance or a snap grid you altered, and it takes minutes to confirm from the graph. Without the metric, the same investigation starts by suspecting the data and often ends there, because the repaired output looks fine.
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.
One last operational note: repair belongs in the ingestion job, never in the read path. A pipeline that calls make_valid lazily whenever a consumer asks for geometry pays the repair cost on every read, produces a different result depending on which GEOS build served the request, and — worst of all — leaves the invalid geometry in storage where the next consumer will meet it again. Repair once, at the boundary, write the repaired geometry with its drift measurement recorded, and let everything downstream assume validity as an invariant rather than re-establishing it.
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.