Fixing Self-Intersecting Parcel Polygons with make_valid

The scenario: an overlay raises TopologyException: Input geom 1 is invalid, someone applies make_valid to the whole layer, the overlay runs, and the parcel count comes out 12 higher than the input. A bowtie was repaired into two polygons, each inherited the original parcel identifier, and the acreage is now double-counted for every one of those parcels. This page repairs geometry without that outcome, and it sits under spatial data quality and validation.

Root-cause analysis

A blanket repair fails because “invalid” covers several defects with different correct treatments.

  1. A bowtie self-intersection. The ring crosses itself once, and make_valid correctly returns a MultiPolygon of the two lobes. Correct geometrically, and a data error if the row keeps one identifier — the parcel is now two rows or one multipart geometry, and downstream counts change.
  2. A zero-width spike or a duplicate vertex. Two vertices coincide or a spur doubles back with no area. make_valid will handle it, but set_precision with a grid size is cheaper, deterministic and does not change the polygon’s structure.
  3. A hole outside its shell. A ring recorded as an interior that lies wholly outside the exterior. The repair is a ring reassignment — the “hole” is a separate polygon — and make_valid may discard it entirely, silently losing a parcel.
  4. Ring orientation. Not invalid under the OGC rules that GEOS enforces, but invalid under RFC 7946, which is why a layer that passes is_valid can still be rejected by a permitting portal.
Invalidity reasons by frequency, and the repair each needs A table of four invalidity reasons from a national parcel extract of 412 invalid geometries. Repeated Point: 396 occurrences, repaired by set_precision, area moved under one part per million, structure preserved. Self-intersection: 12 occurrences, repaired by make_valid, produces a multipart result, area moved by the overlapping lobe. Ring Self-intersection: 2 occurrences, repaired by make_valid, structure preserved. Hole lies outside shell: 2 occurrences, needs an explicit ring reassignment because make_valid may discard the ring and lose land. 412 invalid geometries, four reasons, four repairs Repeated Point area moves < 1e-6 · structure kept 396 set_precision(0.001) Self-intersection multipart result · area moves by the lobe 12 make_valid Ring Self-intersection structure kept 2 make_valid Hole lies outside shell make_valid may discard the ring 2 explicit ring reassignment 96% of the work is a vectorised precision snap; the 4% that needs judgement is the 4% worth reading.

Pre-flight validation: read the reason before repairing

shapely.validation.explain_validity names the defect and gives its coordinates. Grouping a layer by reason turns a blanket repair into four targeted ones.

python
import geopandas as gpd
from shapely.validation import explain_validity


def classify_invalidity(gdf: gpd.GeoDataFrame) -> gpd.pd.DataFrame:
    """Group invalid geometry by the reason GEOS gives, with an example each."""
    invalid = gdf[~gdf.is_valid]
    if invalid.empty:
        return gpd.pd.DataFrame(columns=["reason", "count", "example_index"])

    reasons = invalid.geometry.apply(explain_validity)
    # The reason string carries coordinates; the kind is the part before the bracket.
    kind = reasons.str.split("[").str[0].str.strip()
    summary = (
        gpd.pd.DataFrame({"kind": kind, "reason": reasons, "idx": invalid.index})
        .groupby("kind")
        .agg(count=("idx", "size"), example_index=("idx", "first"), example=("reason", "first"))
        .reset_index()
        .sort_values("count", ascending=False)
    )
    return summary

Running this first is what distinguishes “412 invalid geometries” from “398 duplicate vertices, 12 bowties and 2 holes outside their shell” — three different repairs with three different risks.

Fix implementation

python
import geopandas as gpd
import shapely
from shapely.validation import explain_validity, make_valid


def repair_parcels(
    gdf: gpd.GeoDataFrame,
    *,
    id_field: str = "parcel_id",
    precision_grid_m: float = 0.001,
    area_tolerance: float = 1e-6,
) -> tuple[gpd.GeoDataFrame, gpd.pd.DataFrame]:
    """Repair by reason, keep one row per parcel, and record what each repair moved."""
    out = gdf.copy()
    log = []

    invalid_mask = ~out.is_valid
    for idx in out.index[invalid_mask]:
        geom = out.at[idx, "geometry"]
        reason = explain_validity(geom)
        before = geom.area

        if "Repeated Point" in reason or "Self-intersection" in reason and geom.is_simple:
            # Precision snapping is deterministic and preserves structure.
            repaired = shapely.set_precision(geom, precision_grid_m)
        else:
            repaired = make_valid(geom)

        # A repair that returns several polygons must stay ONE row for this parcel.
        if repaired.geom_type == "GeometryCollection":
            polys = [g for g in repaired.geoms if g.geom_type in ("Polygon", "MultiPolygon")]
            repaired = shapely.union_all(polys) if polys else repaired
        if repaired.geom_type == "MultiPolygon":
            parts = len(repaired.geoms)
        else:
            parts = 1

        out.at[idx, "geometry"] = repaired
        log.append({
            id_field: out.at[idx, id_field],
            "reason": reason.split("[")[0].strip(),
            "area_before": before,
            "area_after": repaired.area,
            "area_delta": repaired.area - before,
            "parts_after": parts,
        })

    report = gpd.pd.DataFrame(log)
    if not report.empty:
        report["area_delta_pct"] = report["area_delta"] / report["area_before"].replace(0, float("nan")) * 100
    return out, report

The two decisions that keep the parcel count stable are collapsing a GeometryCollection to its polygonal parts and leaving a MultiPolygon as one row. A repair that explodes into several rows is almost never what a parcel layer wants — the parcel is still one legal object.

A bowtie under three repairs Three panels over the same bowtie polygon whose ring crosses itself once, forming a larger lobe of 12.4 hectares and a smaller one of 1.8. The first panel, buffer(0), shows only the larger lobe retained and is annotated as silently losing 1.8 hectares. The second, make_valid, shows both lobes retained as a MultiPolygon totalling 14.2 hectares, annotated as correct provided the result stays on one row. The third, set_precision, shows the unchanged bowtie, annotated as still invalid because a precision snap cannot resolve a genuine crossing. The same bowtie, three tools, three outcomes buffer(0) 12.4 ha — 1.8 ha lost silently make_valid 14.2 ha — MultiPolygon, one row set_precision unchanged — still invalid buffer(0) is the dangerous one: it succeeds, returns a valid polygon, and the missing lobe leaves no trace in the output. The area assertion in the repair report is what catches it. A MultiPolygon result belongs on one row under the original parcel identifier — exploding it to several rows double-counts the parcel in every downstream total.

Fallback routing and performance tuning

  • Prefer set_precision where it applies. It is deterministic, orders of magnitude cheaper than make_valid on a large layer, and it cannot restructure a polygon into a collection.
  • Never use buffer(0) on a parcel layer. It works, and it silently drops the smaller lobe of a bowtie — which is the one outcome nobody notices until the acreage is challenged.
  • Repair before the overlay, not inside it. GEOS will raise mid-operation otherwise, and the partial result is discarded, so the whole overlay is repeated.
  • Vectorise with shapely.make_valid on the array. Shapely 2 applies it over a GeoSeries without a Python loop, which on a national layer is the difference between minutes and an hour.
  • Keep the pre-repair geometry. A repair is a modification of source data, and the original is what an auditor asks for when a boundary is disputed.

Downstream validation

python
def assert_repair_conservative(report, *, max_area_shift_pct: float = 0.5, max_new_parts: int = 1) -> None:
    """A repair should fix topology, not redraw parcels."""
    if report.empty:
        return
    worst = report["area_delta_pct"].abs().max()
    assert worst <= max_area_shift_pct, (
        f"a repair moved {worst:.3f}% of a parcel's area — inspect before accepting"
    )
    exploded = report[report["parts_after"] > max_new_parts]
    assert exploded.empty, (
        f"{len(exploded)} parcels became multipart: {list(exploded['parcel_id'])[:5]} — "
        "confirm these are genuinely multipart holdings"
    )
Area moved by repair, against the acceptance threshold A horizontal scale of area change from zero to fifteen percent, with a green acceptance band below 0.5 percent. Three repairs are plotted: a precision snap at under 0.0001 percent, well inside the band; a make_valid bowtie repair at 0.31 percent, inside the band but flagged for review; and a buffer(0) that dropped a lobe at 12.7 percent, far outside and failing the assertion. A note records that the assertion is what turns a silent geometry change into a build failure. How much area a repair moved — and whether that is acceptable 0.00001% 0.001% 0.1% 1% 10% accepted: under 0.5% precision snap bowtie repair buffer(0) dropped a lobe assert report["area_delta_pct"].abs().max() <= 0.5 — one line, and it is the only thing standing between a topology repair and a redrawn parcel boundary.

What a repair is allowed to change

A useful discipline is to state, before running anything, which properties a repair may alter.

Area may move slightly. Snapping a duplicate vertex to a millimetre grid changes area by a vanishing amount; repairing a bowtie changes it by the overlapping lobe, which can be a real fraction. The first is noise and the second is a fact about the source that deserves a look.

Topology must improve. Every output must be valid, and no repair may introduce an overlap with a neighbouring parcel that was not there before. A repair that fixes one polygon by growing it into its neighbour has traded a validity error for a boundary dispute.

Identity must be preserved. One input row is one output row. Where the repair genuinely produces disjoint parts, they belong in one multipart geometry under the original identifier, not in several rows — unless the parcel truly is several legal parcels, which is a data-modelling decision rather than a repair.

Attributes must not move. It sounds obvious, and the common violation is subtle: a repair implemented as a spatial rebuild that re-joins attributes by position rather than by identifier scrambles them whenever the row order changes.

Writing these four down turns “we repaired the layer” into something a reviewer can check, and the area_delta column in the report above is the evidence for the first two.

Frequently asked questions

Is make_valid ever the wrong tool?

For a hole recorded outside its shell, yes — it may discard the ring entirely rather than promote it to its own polygon, which silently loses land. That case wants an explicit ring reassignment. It is also the wrong tool when set_precision would do, not because it is incorrect but because it can restructure a polygon that only needed a vertex snapped.

Why did a repaired layer fail a permitting portal that accepts invalid geometry?

Almost certainly ring winding. GEOS validity does not constrain orientation, but RFC 7946 requires exterior rings counter-clockwise and interior rings clockwise, and a strict consumer reads a reversed exterior as the complement of the polygon. Enforce winding at export, as covered in exporting compliance overlay results to GeoJSON.

What precision grid should set_precision use?

One that is finer than the survey tolerance and coarser than floating-point noise — a millimetre in a projected metric frame is a good default. Coarser than a centimetre starts to move real boundaries; finer than a micrometre does not remove the duplicate vertices it was called for.

Should invalid geometry ever be dropped instead of repaired?

Only when the row is meaningless — a zero-area collapse or a geometry outside the study area by hundreds of kilometres. Dropping a repairable parcel removes land from the analysis without saying so, which is the same failure as silently repairing it badly. Quarantine and report is the middle path.

How do I repair a whole national layer efficiently?

Classify first, then apply the cheap repair to the large group and the expensive one to the small group. On a typical national parcel extract, upwards of 95 percent of invalid geometries are repeated points that set_precision fixes in one vectorised call, leaving a few hundred genuine self-intersections for make_valid.

Does repairing change the CRS or the extent?

Neither, and asserting both is a cheap way to catch a repair implemented as an accidental reprojection. Check that the output CRS matches the input and that the total bounds have not moved by more than the precision grid; both are one line and both have caught real bugs.