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
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_crsgives 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_validrewrites coordinates and would otherwise invalidate every downstream hash.
When the Repair Should Not Run at All
Three cases are worth excluding before the repair rather than reviewing after it.
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.