Repairing Self-Intersecting Polygons with make_valid Jump to heading

A self-intersecting polygon is the most common invalid geometry in municipal and utility data, and it is almost always the fossil of a digitizing session: a boundary traced back over itself, a parcel split applied twice, a dissolve run on data that already overlapped. This procedure repairs it structurally, decides explicitly whether the repair is acceptable, and leaves a record that says what changed. It implements the repair rung of geometry validity and repair rules, the gate inside Spatial Data Quality Validation & Geometry Integrity that owns single-feature correctness.

The steps map to the phases this site standardizes on: configure (Steps 1–2, classification and precision), execute (Step 3, the repair), validate (Step 4, budget and type policy), and log (Step 5, the quality record). The goal is not to make every polygon valid — some should be rejected — but to make every outcome a decision with a number attached.

Prerequisites checklist Jump to heading

Step 1: Confirm the defect class before repairing Jump to heading

make_valid will happily “repair” a nested-shells geometry too, and the result is usually wrong for that class. Classify first; repair only what the manifest says is repairable.

python
# shapely >= 2.0 — Python 3.10+
from shapely.geometry import Polygon
from shapely.validation import explain_validity

bowtie = Polygon([(0, 0), (10, 0), (0, 10), (10, 10), (0, 0)])

print(bowtie.is_valid)             # False
print(explain_validity(bowtie))    # 'Self-intersection[5 5]'

The coordinate in the message is the node where the boundary crosses. Log it: a dataset whose self-intersections cluster at a handful of coordinates is telling you about one bad edit session, while a scatter across the extent means a systematic problem in the source’s export.

Step 2: Snap to the precision grid first Jump to heading

Repair results depend on floating-point detail, so the input must be normalized before the repair or two runs of the same pipeline can disagree by a nanometre — enough to change whether a repair is judged over budget.

python
# shapely >= 2.0 — Python 3.10+
from shapely import set_precision

GRID = 0.0001            # 0.1 mm, in the CRS's linear units

snapped = set_precision(bowtie, GRID)

Snapping can also resolve the defect outright when the “intersection” was arithmetic noise from a reprojection. Re-test validity after snapping and skip the repair entirely if the geometry is now clean — a repair that was not needed is still a change to the feature.

Step 3: Run the structured repair and inspect what came back Jump to heading

python
# shapely >= 2.0 — Python 3.10+
from shapely import make_valid

repaired = make_valid(snapped)

print(repaired.geom_type)          # 'MultiPolygon' for a classic bowtie
print(len(repaired.geoms))         # 2 — the two lobes, both now valid
print(round(repaired.area, 3))     # 50.0 — the sum of the lobes

This is the moment the procedure earns its existence. The bowtie’s two lobes were always there; the invalid geometry simply had no legal way to express them. make_valid returns them as a multipolygon, preserving the area. The alternative repair that teams reach for first, bowtie.buffer(0), returns a single 25 m² triangle — half the feature, deleted without a warning.

buffer(0) Deletes a Lobe; make_valid Keeps Both Three panels. The first shows the input bowtie with its two lobes and the crossing node, total area fifty square metres. The second shows the result of buffer zero: a single triangular lobe, area twenty-five square metres, with the second lobe absent and no error raised. The third shows the result of make_valid: two separate triangular parts forming a multipolygon, total area still fifty square metres, with the geometry type changed from polygon to multipolygon. Input — invalid bowtie Polygon · area 50 m² Self-intersection at the centre node interior undefined buffer(0) — one lobe survives Polygon · area 25 m² the dashed lobe was discarded no exception, no log line make_valid — both lobes kept MultiPolygon · area 50 m² area preserved, type changed the type change is the decision to make

Step 4: Judge type drift and the area delta against the budget Jump to heading

The repair succeeded geometrically. Whether it is acceptable depends on what the dataset promises.

python
# shapely >= 2.0 — Python 3.10+
ALLOW_TYPE_CHANGE = False      # from validity_rules.yaml, per dataset
AREA_DELTA_BUDGET = 0.001      # 0.1 %


def accept(original, repaired) -> tuple[bool, str, float]:
    if repaired.is_empty or not repaired.is_valid:
        return False, "GEOM_UNREPAIRABLE", 0.0

    if repaired.geom_type != original.geom_type and not ALLOW_TYPE_CHANGE:
        return False, "GEOM_TYPE_DRIFT", 0.0

    baseline = abs(original.buffer(0).area) or 1.0
    delta = abs(repaired.area - baseline) / baseline
    if delta > AREA_DELTA_BUDGET:
        return False, "GEOM_REPAIR_DRIFT", delta

    return True, "", delta


ok, code, delta = accept(snapped, repaired)
print(ok, code, round(delta, 6))   # False GEOM_TYPE_DRIFT 0.0

For a parcel layer the default answer is the one printed above: refuse the repair, quarantine the feature, and let a human decide whether this parcel is genuinely two parcels. For a land-cover layer where multipart features are normal, set allow_type_change: true for that dataset and the same bowtie is accepted with a 0.0 area delta. The important property is that the two datasets differ in a manifest, not in code, and both outcomes are recorded.

Note the buffer(0) in the baseline: the original geometry’s area is itself undefined, so measuring against it needs a defined interpretation, and the deleting behaviour that makes buffer(0) a bad repair makes it a conservative baseline — it never over-states the original area, so the delta never under-states the change.

Step 5: Record the repair in the quality report Jump to heading

python
# pyarrow >= 14 — Python 3.10+
import pyarrow as pa

row = {
    "feature_id":      "PARCEL-14-023-9",
    "run_id":          run_id,
    "ruleset_version": "2.1.0",
    "verdict":         "repaired" if ok else "quarantined",
    "defect":          "self_intersection",
    "code":            code,
    "area_delta":      delta,
    "geos_message":    explain_validity(bowtie),
    "geom_type_in":    snapped.geom_type,
    "geom_type_out":   repaired.geom_type,
}
writer.write_batch(pa.RecordBatch.from_pylist([row], schema=GEOMETRY_GATE_SCHEMA))

Store the verbatim geos_message, not just the classified defect. When a repair policy is revisited a year later, the only reliable way to find every affected feature is the message GEOS produced at the time; the classifier’s mapping may have moved since, and re-running the pipeline over today’s data answers a different question. These rows join the lineage manifest on run_id and feature_id.

Verification Jump to heading

Assert the two properties that actually matter — the repair preserved the area, and the pipeline is idempotent.

python
# pytest >= 7, shapely >= 2.0
def test_make_valid_preserves_area_for_a_bowtie():
    repaired = make_valid(set_precision(bowtie, 0.0001))
    assert repaired.is_valid
    assert abs(repaired.area - 50.0) < 1e-9        # both lobes survived

def test_repair_is_idempotent():
    once = make_valid(set_precision(bowtie, 0.0001))
    twice = make_valid(set_precision(once, 0.0001))
    assert once.equals_exact(twice, tolerance=0.0)  # re-running changes nothing

In the run log, a successful repair looks like this — the delta, not the word “repaired”, is the evidence:

text
INFO quality.geometry repaired Polygon defect=self_intersection area_delta=0.000000
INFO quality.geometry quarantined PARCEL-14-023-9 code=GEOM_TYPE_DRIFT

Troubleshooting Jump to heading

Symptom Likely cause Fix
make_valid returns a GeometryCollection containing lines The input had zero-area spikes as well as a crossing; the spikes repair to linestrings Extract only the polygonal parts, then re-test; if the polygonal area is below budget, quarantine — the feature was mostly spike
Area delta is enormous (over 50%) The “self-intersection” was actually a ring traversed in the wrong direction, so one lobe cancels the other Check ring orientation first — see ring orientation and winding order
Repairs differ between two machines set_precision is not being applied, so results depend on the floating-point history of each input Snap before repairing, and pin grid_size in the manifest rather than passing it per call
TopologyException raised inside make_valid GEOS older than 3.10, or a geometry with non-finite ordinates Check shapely.geos_version; screen for NaN bounds before the repair
Every feature in a layer reports GEOM_TYPE_DRIFT The dataset is legitimately multipart and the manifest says otherwise Set allow_type_change: true for this dataset — and record why in the manifest, since it weakens the one-feature-one-part assumption joins rely on