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

  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.

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.