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.

Four geometries behind one GEOS message Four small drawings. First, a bowtie: a closed ring whose two edges cross at a single interior point, producing two triangular lobes with opposite winding; the noted repair is to split into a multipolygon. Second, a spike: an otherwise simple polygon with a zero-width protrusion where the boundary runs out and immediately back along the same line; the noted repair is to remove the degenerate segment. Third, a self-touch: a ring that meets itself at exactly one vertex without crossing, forming a figure of eight; the noted repair is to separate the parts at the pinch point. Fourth, a ring escape: an interior ring whose boundary extends beyond the exterior shell; the noted repair is to re-derive the hole rather than patch the linework. Bowtie edges cross once lobes wind opposite ways → split to multipolygon Spike boundary doubles back zero-width protrusion → drop the degenerate run Self-touch meets at one vertex no crossing — a pinch → separate at the pinch Ring escape hole crosses the shell interior is disconnected → re-derive the hole

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
Linework versus structure on the same bowtie Three panels left to right. The input panel shows a bowtie whose upper and lower lobes are the same size, labelled as total area A. The linework panel shows the same figure re-noded into two separate triangles, both retained, labelled area A preserved and vertices preserved. The structure panel shows a single triangle where one lobe has been dissolved away, labelled area roughly A over two and one lobe lost. A footer states that any repair which changes area belongs in quarantine with the drift recorded, not in the curated output. Input bowtie · total area A is_valid → False method="linework" MultiPolygon · both lobes area A preserved no vertex moved method="structure" Polygon · one lobe area ≈ A / 2 the other lobe is gone A repair that changes area is a data decision, not a cleanup. Measure the delta, and route anything beyond the budget to quarantine rather than into curated output.

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.

Which pipeline stage created the self-intersection Four stages arranged left to right along a pipeline. Digitisation or capture produces genuine crossing edges, and the corrective action is validation at the producer's entry point, since the geometry was wrong before it arrived. Generalisation produces crossings where the simplification tolerance exceeds the width of a local feature, and the corrective action is a smaller tolerance or a topology-preserving simplifier. Overlay operations leave slivers and spikes from floating-point clipping, and the corrective action is snapping to a precision grid before the overlay rather than repairing after it. Reprojection collapses near-coincident vertices into degenerate runs, and the corrective action is to densify the geometry before transforming. A footer distinguishes the first stage, which is somebody else's data defect, from the remaining three, which are parameter choices inside your own pipeline. Digitisation hand-drawn crossing genuine geometry error validate at the producer not repairable downstream Generalisation tolerance > feature width edge dragged across edge lower the tolerance or preserve topology Overlay clip residue slivers and spikes snap before, not after set_precision on inputs Reprojection vertices collapse degenerate runs appear densify first then transform Only the first stage is somebody else’s data defect. The other three are parameter choices in your own pipeline — which means a rising invalid-geometry count after a release is far more often a tolerance you changed than data that got worse.

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

  1. buffer(0) dropping area. On a bowtie, buffer(0) can return only one lobe, silently halving the area; prefer make_valid and bound the area change.
  2. Repair changes geometry type. make_valid may return a GeometryCollection; downstream code expecting a Polygon breaks — assert or coerce the result type.
  3. Micro self-touch below grid. Sub-nanometre self-touches from float noise are best removed with set_precision before repair, not by a coarse buffer.
  4. Valid but degenerate. A zero-area “polygon” (all collinear vertices) can pass some checks; assert a minimum area where a real polygon is expected.
  5. 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.