Repairing Invalid Geometries with make_valid Safely

shapely.make_valid always returns something. That is its defining property and the reason it is dangerous in a pipeline: a bow-tie polygon becomes a MultiPolygon, a zero-area sliver becomes a LineString, and a polygon whose ring is entirely degenerate becomes an empty GeometryCollection — and every one of those outcomes is reported as success. This guide sits within geometry validation patterns and covers repairing invalid geometry while asserting that the repair preserved what the downstream code assumes.

The rule the rest of this follows: repair is a transformation, not a fix. Anything a transformation can change is something a test must pin down.

Root cause: validity and identity are different properties

An invalid geometry violates the Simple Features rules — a self-intersecting exterior ring, an interior ring outside its shell, a repeated point producing a zero-length segment. make_valid produces a geometry that satisfies those rules and is as close as the algorithm can manage to the input. “As close as it can manage” is not a guarantee about type, area, or count, and code downstream of the repair almost always assumes all three.

Four outcomes of make_valid, all reported as success Four repair outcomes are compared. A self-intersecting bow-tie polygon becomes a MultiPolygon of two parts, breaking any downstream assumption that the geometry is a single Polygon. A polygon carrying a zero-width spike becomes a GeometryCollection mixing a polygon with a linestring, so area-based aggregation silently drops the linear part. A polygon whose ring is entirely degenerate becomes an empty GeometryCollection, so the feature disappears from the output with no error raised anywhere. A polygon containing only a duplicated point is repaired in place with its type and area preserved, which is the single outcome most pipeline code is actually written to handle. INVALID INPUT make_valid OUTPUT WHAT SILENTLY BREAKS bow-tie (self-intersecting ring) one Polygon in MultiPolygon, 2 parts type changed a Polygon type assumption downstream polygon with a zero-width spike one Polygon in GeometryCollection polygon + linestring area aggregation drops the line part fully degenerate ring one Polygon in empty GeometryCollection nothing at all the feature vanishes, no error raised duplicated point one Polygon in Polygon, same area repaired in place nothing — the case code expects

Only the last row is what most pipeline code was written for, and it is the least common in real data. The other three pass through unnoticed and surface much later — as a count that does not reconcile, an area total that drifts, or a feature that quietly stopped existing.

The three invariants worth asserting

For a repair to be safe in a pipeline, three things must hold, and each is checkable in one line.

Invariant Check Why it matters
Type is preserved or explicitly widened geom_type in an allowed set A GeometryCollection breaks writers and joins
Area is preserved within tolerance AoutAin/Ainε\lvert A_{out} - A_{in}\rvert / A_{in} \le \varepsilon Large loss means the repair discarded real extent
Nothing became empty not geom.is_empty An empty result is data loss reported as success

The area check is the one that carries the most information. A valid repair of a bow-tie preserves total area exactly, because the two lobes are simply separated. A repair that loses more than a fraction of a percent has thrown something away, and the threshold ε\varepsilon is the knob that separates acceptable cleanup from silent deletion.

Step-by-step implementation

Step 1 — Repair, then normalise the type

make_valid may return a GeometryCollection; the pipeline almost certainly cannot accept one. Extract the parts matching the input’s dimension and rebuild:

from shapely import make_valid
from shapely.geometry import MultiPolygon, Polygon
from shapely.geometry.base import BaseGeometry


def repair_polygonal(geom: BaseGeometry) -> BaseGeometry:
    """make_valid, then keep only the polygonal parts of the result."""
    if geom.is_valid:
        return geom

    fixed = make_valid(geom)

    if fixed.geom_type == "GeometryCollection":
        parts = [g for g in fixed.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
        if not parts:
            raise ValueError("repair produced no polygonal geometry")
        fixed = parts[0] if len(parts) == 1 else MultiPolygon(
            [p for g in parts for p in (g.geoms if g.geom_type == "MultiPolygon" else [g])]
        )
    if fixed.is_empty:
        raise ValueError("repair produced an empty geometry")
    return fixed

Raising rather than returning the empty result is the important decision. An empty geometry flowing onward is indistinguishable from a feature that legitimately has no extent, and by the time anyone notices, the row that produced it is unidentifiable.

Step 2 — Assert the area survived

AREA_TOLERANCE = 1e-9        # relative


def repair_with_area_check(geom: BaseGeometry, tol: float = AREA_TOLERANCE):
    before = geom.area
    fixed = repair_polygonal(geom)
    after = fixed.area

    if before > 0:
        drift = abs(after - before) / before
        if drift > tol:
            raise ValueError(
                f"repair changed area by {drift:.2%} "
                f"({before:.6f} -> {after:.6f})"
            )
    return fixed

geom.area on an invalid polygon is well defined in GEOS but not always meaningful — a bow-tie’s computed area is the difference of its two lobes, not their sum, because the ring winds in opposite directions. That is why bow-tie repairs frequently trip this check with a large positive drift, and why the correct response is to widen the tolerance for known bow-ties rather than to remove the check. The self-intersecting polygon guide covers detecting that case specifically.

Why the area of a bow-tie polygon misleads a drift check A bow-tie polygon is drawn as two triangular lobes meeting at a crossing point, with arrows showing that the ring traces one lobe clockwise and the other counter-clockwise. Because the shoelace formula sums signed contributions, the computed area of the ring is the difference between the two lobes rather than their sum, so a symmetric bow-tie reports an area close to zero despite covering real extent. The repaired result is shown as a MultiPolygon of two separate parts whose combined area is the true covered extent, which makes the apparent drift enormous even though the repair is correct. invalid: one ring, two lobes, opposite winding clockwise counter-clockwise signed contributions cancel reported area ≈ 0 make_valid valid: MultiPolygon, two parts, consistent winding reported area = true covered extent apparent drift is enormous, repair is correct Widen the tolerance for geometries already identified as bow-ties. Do not widen it globally — that is what hides real area loss on every other repair in the batch. Classify first, then choose the tolerance the class deserves.

Step 3 — Record what was repaired, per feature

A repair that runs silently over a whole layer is a data change with no record. Emit one row per repair so the change is reviewable:

from dataclasses import dataclass


@dataclass(frozen=True)
class RepairRecord:
    feature_id: str
    reason: str          # from shapely.validation.explain_validity
    type_before: str
    type_after: str
    area_before: float
    area_after: float


def repair_layer(gdf, id_col="id"):
    from shapely.validation import explain_validity

    records, repaired = [], []
    for fid, geom in zip(gdf[id_col], gdf.geometry):
        if geom is None or geom.is_valid:
            repaired.append(geom)
            continue
        fixed = repair_with_area_check(geom)
        records.append(RepairRecord(
            feature_id=str(fid),
            reason=explain_validity(geom),
            type_before=geom.geom_type,
            type_after=fixed.geom_type,
            area_before=geom.area,
            area_after=fixed.area,
        ))
        repaired.append(fixed)

    gdf = gdf.copy()
    gdf["geometry"] = repaired
    return gdf, records

explain_validity gives the specific failure — Self-intersection[529012 180334] — with coordinates, which is what makes an individual repair investigable rather than merely counted.

Step 4 — Gate on the repair rate, not on zero repairs

Requiring zero invalid geometries in real data is a gate that gets disabled within a month. Requiring the rate not to increase is a gate that survives:

import pytest

MAX_REPAIR_RATE = 0.005          # 0.5% of features, agreed with the data owner


def test_repair_rate_within_budget(source_layer):
    gdf, records = repair_layer(source_layer)
    rate = len(records) / len(gdf)
    assert rate <= MAX_REPAIR_RATE, (
        f"{len(records)}/{len(gdf)} features required repair ({rate:.2%})"
    )


def test_no_repair_changed_geometry_type(source_layer):
    _, records = repair_layer(source_layer)
    changed = [r for r in records if r.type_before != r.type_after]
    assert not changed, f"{len(changed)} repairs changed type: {changed[:3]}"

The second test is the one that catches the failure this guide is about. A type change is always worth a human decision, and a pipeline that widens Polygon to MultiPolygon on its own will eventually hand a MultiPolygon to a writer or a join that cannot take one.

Repair once at the entry point, assert after every transforming stage A pipeline is drawn from source to output. A repair pass sits immediately after the source is read, so every downstream stage sees valid geometry and the repair log references source feature identifiers that a data owner can look up directly. Three transforming stages follow — reprojection, simplification and clipping — each of which can itself create invalidity, and each carries a cheap validity assertion on its output so that a failure names the stage responsible. A note contrasts this with repairing at write time, which references identifiers the pipeline invented and cannot indicate which stage introduced the problem. source read repair log keyed by source id reproject simplify clip write assert is_valid assert is_valid assert is_valid Each of the three stages can create invalidity, so each needs its own assertion — the assertion is what names the guilty stage. Repairing at the write step instead logs identifiers the pipeline invented and cannot say where the problem entered.

Failure modes and edge cases

buffer(0) is not a substitute. The older idiom repairs many polygons and silently deletes others — a bow-tie’s smaller lobe disappears entirely, with no collection and no warning. make_valid at least tells you the structure changed. Prefer it, and if legacy code uses buffer(0), the area check above is what exposes the difference.

GEOS version changes the result. make_valid gained a structure-preserving mode in GEOS 3.10 and its behaviour on collapsed rings has changed across releases. Repair output is therefore a function of your GEOS build, which is a strong reason to pin GEOS, PROJ, and GDAL in the test image and to store repaired output as a fixture only alongside the version that produced it.

Repair is not idempotent across a reprojection. A geometry valid in EPSG:27700 can become invalid after transformation to EPSG:4326 because the coordinates move and near-degenerate rings collapse at the new precision. Repair after the final transformation, not before, or you will repair twice with different results.

Interior rings that touch the shell at one point are valid. They look wrong and some tools flag them; is_valid accepts them. A repair pass that “fixes” them is changing correct data.

Null geometry is not invalid geometry. None in a GeoDataFrame geometry column has is_valid undefined and will raise or return False depending on the version. Handle it explicitly, as repair_layer does, so a null row does not become a silently repaired empty polygon.

Where the repair belongs in the pipeline

Repair placement decides whether the record of what changed is useful. Repairing at read time, immediately after loading the source, means every downstream stage sees valid geometry and the repair log lines up with source feature identifiers that a data owner can actually look up. Repairing at write time means the log references identifiers the pipeline invented, and the invalidity may have been introduced by an intermediate step rather than present in the source — which is a different problem with a different owner.

The exception is any stage that can itself create invalidity. Reprojection, simplification, and clipping all can, so a pipeline containing them needs a validity assertion after each rather than a single repair at the front. The assertion is cheap: assert gdf.geometry.is_valid.all() over a stage’s output costs a fraction of the stage itself and localises the blame precisely. Repair once at the boundary where data enters, assert everywhere it is transformed, and reserve a second repair pass for the specific stage that a failing assertion identifies.

Conclusion

make_valid is the right tool and its guarantee is narrower than it looks: valid output, not equivalent output. Wrap it so the result’s type is normalised, its area is compared against the input within a tolerance you chose deliberately, empties raise instead of flowing onward, and every repair leaves a record. Then gate on the repair rate rather than on perfection, so the check keeps running once real data arrives.