This guide is part of Geometry Repair with Shapely & GeoPandas, within the broader Automated Vector & Raster Cleaning Workflows reference.

Repairing Invalid MultiPolygons with make_valid

make_valid always returns valid geometry. It does not promise to return the geometry you meant, and on multipart input the difference between those two matters.

Why Multipart Repair Needs Care

  • Part counts change. A repair can split one part into three or merge two into one, and any downstream count keyed on parts moves with it.
  • The output type changes. A polygon can become a GeometryCollection, which most sinks refuse.
  • Area moves silently. A bowtie’s area before repair is not its area after, so any metric computed earlier is wrong.
  • Holes can be lost. An interior ring that lies outside its shell is deleted rather than reassigned, which is correct and rarely what the source intended.

Version and Environment Compatibility

Component Version Note
Shapely >=2.1 make_valid(method=...) exposing structured repair
Shapely 2.0.x make_valid available, linework only
GEOS >=3.10 Native MakeValid; 3.12+ for the structured method
GeoPandas >=1.0 Vectorized application over a GeoSeries
pip install "shapely>=2.1" "geopandas>=1.0"

What Each Method Does to the Same Input

Linework and structured repair on one geometry An invalid two-part geometry whose interior ring crosses its shell. The linework method nodes all rings and rebuilds polygons from the arrangement, producing three parts with a modified outline. The structured method keeps the original rings and re-assigns the misplaced interior, producing two parts whose outlines match the input. input · interior ring crosses the shell is_valid → False "Hole lies outside shell" linework · rings re-noded 3 parts, outline changed robust on any input structured · rings kept 2 parts, outline preserved needs individually valid rings

The repair_multipolygons Recipe

from __future__ import annotations

import logging

import geopandas as gpd
import numpy as np
import shapely

logger = logging.getLogger(__name__)


def repair_multipolygons(
    gdf: gpd.GeoDataFrame,
    metric_crs: int | None = None,
    area_budget: float = 0.001,
    method: str = "structure",
) -> tuple[gpd.GeoDataFrame, gpd.GeoDataFrame]:
    """Repair invalid polygonal geometry and separate the repairs that moved too much.

    Returns (repaired, for_review). Anything whose area moved by more than
    `area_budget` is a reinterpretation rather than a repair and is not written.
    """
    geoms = gdf.geometry.values
    invalid = ~shapely.is_valid(geoms)
    if not invalid.any():
        return gdf, gdf.iloc[0:0]

    measure_crs = metric_crs or (gdf.crs if gdf.crs and gdf.crs.is_projected else gdf.estimate_utm_crs())
    before_area = gdf.to_crs(measure_crs).geometry.area.to_numpy()
    before_parts = shapely.get_num_geometries(geoms)

    repaired = geoms.copy()
    try:
        repaired[invalid] = shapely.make_valid(geoms[invalid], method=method)
    except TypeError:  # Shapely < 2.1 has no method argument
        logger.warning("shapely lacks make_valid(method=); falling back to linework")
        repaired[invalid] = shapely.make_valid(geoms[invalid])

    # A repair may produce mixed-dimension output; keep only the polygonal parts.
    repaired = np.array([_polygonal(geom) for geom in repaired], dtype=object)

    out = gdf.copy()
    out = out.set_geometry(gpd.GeoSeries(repaired, index=gdf.index, crs=gdf.crs))

    after_area = out.to_crs(measure_crs).geometry.area.to_numpy()
    with np.errstate(divide="ignore", invalid="ignore"):
        drift = np.where(before_area > 0, np.abs(after_area - before_area) / before_area, 0.0)

    out["repair_method"] = np.where(invalid, method, None)
    out["parts_before"] = before_parts
    out["parts_after"] = shapely.get_num_geometries(repaired)
    out["area_drift"] = drift

    excessive = drift > area_budget
    logger.info("repaired %d geometries; %d exceeded the area budget",
                int(invalid.sum()), int(excessive.sum()))
    return out.loc[~excessive], out.loc[excessive]


def _polygonal(geom):
    """Return only the polygonal content of a possibly mixed-dimension geometry."""
    if geom is None or geom.is_empty:
        return geom
    if geom.geom_type in ("Polygon", "MultiPolygon"):
        return geom
    parts = [g for g in getattr(geom, "geoms", []) if g.geom_type in ("Polygon", "MultiPolygon")]
    if not parts:
        return shapely.Polygon()
    return shapely.union_all(parts)

Key Implementation Notes

  • Area is measured in a projected CRS. A drift ratio computed from degrees is arithmetic without meaning, and estimate_utm_crs gives a reasonable local metric system when none is configured.
  • The structured method is the default, with a fallback. It preserves outlines where the input allows it; older Shapely builds raise on the argument, and the fallback keeps the function usable across environments.
  • Mixed-dimension output is reduced to polygons. The line fragments a repair produces are real information, but they are not features of a polygon layer; dropping them explicitly beats a writer rejecting the frame.
  • Part counts are recorded before and after. A repair that turns one part into four is a schema question, not a geometry question, and the columns make it visible.
  • Excessive drift is separated, not written. A repair that changes area by more than a tenth of a percent has reinterpreted the feature, and a human should see it — the principle set out in geometry repair with Shapely and GeoPandas.
  • Valid geometry is untouched. The mask means the repair runs only where needed, which matters because make_valid rewrites coordinates and would otherwise invalidate every downstream hash.
Area drift separates repairs from reinterpretations A histogram of area drift across six hundred repaired geometries. The overwhelming majority sit below a tenth of a percent, which is the numerical noise of re-noding. A small group sits between one and five percent. Two outliers exceed twenty percent, which indicates the repair produced a materially different feature and should not be written without review. area drift per repaired geometry · 600 features 561 31 6 2 <0.1% 0.1–1% 1–5% 5–20% >20% The threshold belongs in configuration per layer: a coastline tolerates drift a cadastral parcel does not.

When the Repair Should Not Run at All

Three cases are worth excluding before the repair rather than reviewing after it.

When not to repair automatically Three exclusions from automatic repair. In a quality-reporting pipeline the invalidity is the measurement, so repairing destroys it. A repair that moves area beyond the budget is a reinterpretation and belongs in review. A recurring invalidity in a weekly feed is worth one message to the publisher rather than a permanent workaround. exclude from automatic repair the invalidity is the finding — a supplier-quality report measures it area moves past the budget — that is a reinterpretation, not a repair the source could fix it — one email beats carrying their bug forever Repair what is mechanical; escalate what is semantic. The validity message usually tells you which is which.

Features whose invalidity is the finding. A supplier-quality report measures invalidity; repairing first destroys the measurement.

Geometries with fewer than four coordinates in a ring. Nothing can be reconstructed from a degenerate ring, and passing them through produces empty geometry that then has to be filtered anyway. Route them straight to quarantine.

Layers where a topological relationship must hold. In a coverage, repairing each polygon independently reintroduces the gap-and-overlap problem described in simplifying polygons while preserving shared boundaries. Repair the coverage as a whole, or accept that the result needs a topology pass afterwards.

Troubleshooting Multipart Repair

Symptom Likely cause Fix
Writer rejects the frame GeometryCollection in the geometry column Extract polygonal parts, as _polygonal does
Row counts unchanged, part counts jumped Repair split parts Record parts_before/parts_after; decide on explode
Area drift on every feature Drift measured in degrees Compute area in a projected CRS
TypeError on method= Shapely older than 2.1 Upgrade, or accept the linework fallback
Holes disappeared Interior ring lay outside its shell Expected; review the source’s ring assembly
Hashes changed for valid features Repair applied to the whole column Apply only where is_valid is False

Integration Note

The repaired frame continues to the writer; the review frame goes to the dead-letter path described in quarantining invalid features to a dead-letter table, carrying its drift and part-count columns so a reviewer can see what the repair would have done. Repair belongs after validation and before any measurement, which is the ordering argued for in automated vector and raster cleaning workflows.

Repairing Before Versus After Reprojection

The order of repair and reprojection changes the result, and neither order is universally right.

Repairing before reprojection works on the coordinates the source published, which is what the data provider intended and what their own quality checks measured. It is the better choice when the invalidity is structural — crossed rings, misassigned holes — because those are properties of the geometry rather than of the coordinate system.

Repairing after reprojection catches the invalidity that reprojection itself introduces. Transforming a polygon whose vertices are nearly coincident can push them across each other, and a geometry that was valid in degrees can arrive invalid in metres. This is common at high latitudes and around projection boundaries.

The practical answer is to validate at both points and repair where the failure appears. That is one extra vectorized check per stage — cheap, and it also attributes the invalidity to the stage that produced it, which is exactly the visibility argued for in the drift checks elsewhere in this section.