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.
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 | 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
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.
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.
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.
Related
- Geometry Validation Patterns — the parent strategy this fits into
- Detecting and Fixing Self-Intersecting Polygons — classifying the invalidity before repairing it
- Validating Polygon Topology with GeoPandas — the layer-level checks that run after repair
- Validating Shared Boundaries Between Adjacent Polygons — what repair can quietly break between neighbours
- Pinning GDAL/PROJ Versions in Docker Test Images — making repair output reproducible