buffer(0) Does Not Fix My Invalid Geometry: What to Do Instead
On this page
Problem statement
The classic one-liner is still everywhere:
gdf["geometry"] = gdf.geometry.buffer(0)
print(gdf.geometry.is_valid.all())
Sometimes it works. On the Isle of Wight's raw OpenStreetMap multipolygons, read straight from the .osm.pbf with GDAL, it repaired all 19 invalid features without changing their area β and rewrote 86,055 of the 86,061 geometries that were already valid, because a buffer rebuilds every ring it touches.
When it fails, it fails quietly. buffer(0) was never a repair function. It is a side effect of how GEOS builds buffers: the operation re-nodes the rings and returns whatever polygonal result falls out. For the common self-intersection that result is the fix you wanted. For others it drops half the shape, or returns an empty geometry β and it does nothing useful for non-polygonal input.
Where it goes wrong, measured on the small constructed shapes in the examples below:
- lines and points:
buffer(0)on aLineStringreturns an empty polygon - a bow-tie polygon: one lobe is kept, the other discarded β area 2 becomes 1
- a hole outside its shell: the hole disappears
- NaN coordinates: it returns a polygon without complaint, built from the finite coordinates
- valid geometries that are merely not simple β a different property, which it does not address
Quick answer
Use make_valid with method="structure", and check the result:
- find out why each geometry is invalid with
shapely.validation.explain_validity - repair with
make_valid(method="structure", keep_collapsed=False)β the result stays polygonal - compare geometry type and area before and after; a repair that changes either needs review
- snap precision after the repair, never before β
set_precisionraises on invalid input - re-check validity and fail the pipeline if anything is still invalid
from shapely.validation import explain_validity
gdf = osm.copy() # the Isle of Wight multipolygons, EPSG:27700
bad = gdf[~gdf.geometry.is_valid]
print(f"{len(bad)} invalid of {len(gdf):,}")
print(bad.geometry.head(3).apply(explain_validity).to_list())
before_area = gdf.geometry.area.sum()
mask = ~gdf.geometry.is_valid # repair only these: structure mode rebuilds every ring
gdf.loc[mask, "geometry"] = gdf.loc[mask, "geometry"].make_valid(method="structure", keep_collapsed=False)
print("still invalid:", (~gdf.geometry.is_valid).sum())
print(f"area change: {(gdf.geometry.area.sum() - before_area) / before_area:.4%}")
print(gdf.geom_type.value_counts().to_dict())
19 invalid of 86,080
['Self-intersection[458382.951875285 92054.0699323847]', 'Self-intersection[458626.186985012 82399.7905039306]', 'Self-intersection[454466.763903775 85739.6641310109]']
still invalid: 0
area change: 0.0000%
{'MultiPolygon': 86064, 'Polygon': 16}
GeoPandas 1.1.4, used here, passes method and keep_collapsed through to Shapely 2.1. On an older GeoPandas, call shapely.make_valid(gdf.geometry.values, method="structure") directly.
The repair ladder
Diagnose the damage, then repair with make_valid
Find out what is actually wrong
is_valid is a boolean; explain_validity is a sentence.
from shapely.validation import explain_validity
def reasons(geoms):
invalid = geoms[~geoms.is_valid]
if invalid.empty: # .str fails on an empty object column
return {}
return invalid.apply(explain_validity).str.split("[").str[0].value_counts().to_dict()
print("Isle of Wight:", reasons(osm.geometry))
print("constructed: ", reasons(cases.geometry))
Isle of Wight: {'Self-intersection': 19}
constructed: {'Self-intersection': 2, 'Hole lies outside shell': 1, 'Holes are nested': 1, 'Ring Self-intersection': 1, 'Too few points in geometry component': 1, 'Invalid Coordinate': 1}
Every one of the 19 real failures is the same thing: an inner ring touching or crossing the outer ring of an OSM multipolygon, the way mappers draw a wood around a clearing that runs up to its edge. The constructed set shows the other reasons you will meet.
The reason determines the right repair. "Self-intersection" is what make_valid handles cleanly. "Too few points" means a degenerate geometry that should be dropped, not repaired. "Invalid Coordinate" is NaN or infinity, and make_valid will not touch it β see below.
What each repair does to each kind of damage
import shapely
rows = []
for name, geom in zip(cases.name, cases.geometry):
def attempt(f):
try:
out = f(geom)
return "empty" if out.is_empty else f"{out.geom_type} {out.area:g}"
except shapely.errors.GEOSException:
return "raises"
rows.append({"case": name, "area": f"{geom.area:g}",
"buffer(0)": attempt(lambda g: g.buffer(0)),
"linework": attempt(lambda g: shapely.make_valid(g)),
"structure": attempt(lambda g: shapely.make_valid(
g, method="structure", keep_collapsed=False))})
print(gpd.pd.DataFrame(rows).to_string(index=False))
case area buffer(0) linework structure
bow-tie 0 Polygon 1 MultiPolygon 2 MultiPolygon 2
line 0 empty LineString 0 LineString 0
hole outside shell 15 Polygon 16 MultiPolygon 17 MultiPolygon 17
overlapping shells 8 Polygon 7 MultiPolygon 6 Polygon 7
nested holes 35 Polygon 36 MultiPolygon 37 Polygon 36
spike 4 Polygon 4 GeometryCollection 4 Polygon 4
three-point ring 0 empty LineString 0 empty
NaN coordinate nan Polygon 0.5 raises Polygon 0.5
Read across a row and the three repairs disagree more often than they agree. buffer(0) halves the bow-tie and loses the stray hole. The default linework mode keeps both bow-tie lobes but treats the overlap between two shells as a place the boundary crosses twice, so the overlap cancels: two squares of area 4 that share a unit square come back as 6, not 7. It also raises on a NaN coordinate. structure mode unions overlapping shells (7), keeps the bow-tie whole, and drops the collapsed spike.
Repair with make_valid
make_valid implements the OGC-defined repair: it decomposes the input into nodes and edges and rebuilds valid components. In the default linework mode it returns every piece of linework it found β as a GeometryCollection if the result mixes polygons and lines. Shapely 2.1 adds the structure-preserving mode:
from shapely import make_valid
fixed = make_valid(geom) # 'linework' (default)
fixed = make_valid(geom, method="structure", keep_collapsed=False) # stays polygonal
method="structure" is usually what a GIS user expects from a polygon repair: shells are unioned, holes are subtracted, and collapsed slivers are dropped rather than returned as lines.
Inspect what the repair produced
A repair that changes the geometry type is a repair that changed your data. The default mode shows why:
bad = osm[~osm.geometry.is_valid]
linework = bad.geometry.make_valid()
structure = bad.geometry.make_valid(method="structure", keep_collapsed=False)
print("types, linework: ", linework.geom_type.value_counts().to_dict())
print("types, structure:", structure.geom_type.value_counts().to_dict())
delta = (linework.area - bad.area) / bad.area
print("linework: features with area change:", int((delta.abs() > 1e-9).sum()),
"| largest:", f"{delta.abs().max():.3%}")
print("structure: area identical to input:", bool((structure.area - bad.area).abs().max() < 1e-6))
types, linework: {'GeometryCollection': 19}
types, structure: {'Polygon': 16, 'MultiPolygon': 3}
linework: features with area change: 1 | largest: 0.155%
structure: area identical to input: True
Every one of the 19 came back from linework as a GeometryCollection: the polygon plus the stretch of boundary where the inner ring ran along the outer one, returned as a line. One of them also gained area, because a hole that touched the shell along an edge was absorbed. structure returned 19 polygons with their area unchanged.
Keep only the parts you want
If you do use linework, filtering the result is your job:
from shapely.geometry import MultiPolygon, Polygon
def polygons_only(geom):
"""Drop non-polygonal fragments left by a repair."""
if geom is None or geom.is_empty:
return None
if isinstance(geom, (Polygon, MultiPolygon)):
return geom
parts = [g for g in getattr(geom, "geoms", []) if isinstance(g, (Polygon, MultiPolygon))]
if not parts:
return None
return parts[0] if len(parts) == 1 else MultiPolygon(
[p for part in parts for p in (part.geoms if part.geom_type == "MultiPolygon" else [part])]
)
kept = linework.map(polygons_only)
dropped = linework.map(lambda g: sum(p.length for p in g.geoms if p.geom_type.endswith("LineString")))
print(kept.geom_type.value_counts().to_dict(), f"| line fragments dropped: {dropped.sum():,.0f} m")
{'Polygon': 15, 'MultiPolygon': 4} | line fragments dropped: 2,231 m
Dropping fragments is a decision, and it belongs in your code where it is visible β not in a side effect of buffer(0).
Handle the geometries make_valid cannot save
Some inputs are not repairable because there is nothing there to repair, and one kind makes make_valid raise rather than return.
import numpy as np
from shapely import get_coordinates
def is_degenerate(geom) -> bool:
if geom is None or geom.is_empty:
return True
coords = get_coordinates(geom)
if len(coords) == 0 or not np.isfinite(coords).all():
return True # NaN or infinite coordinates
if geom.geom_type in ("Polygon", "MultiPolygon"):
repaired = shapely.make_valid(geom, method="structure", keep_collapsed=False)
if repaired.is_empty or repaired.area == 0:
return True # collapses to nothing
return False
drop = cases.geometry.map(is_degenerate)
print("degenerate:", cases.loc[drop, "name"].to_list())
degenerate: ['three-point ring', 'NaN coordinate']
The zero-area test is made on the repaired shape. The first version of this function tested geom.area == 0 on the input and threw away the bow-tie: its two lobes wind in opposite directions, so the shoelace formula gives them areas of +1 and β1 and the invalid polygon reports an area of exactly 0. Area is not meaningful on an invalid geometry.
A ring of three points, or one carrying a NaN coordinate, cannot become valid. buffer(0) returns something anyway; the default make_valid raises GEOSException: β¦ encountered NaN/Inf numbers. Remove these explicitly, before the repair, and record how many you removed.
Snap precision after the repair, not before
Coordinates carrying more precision than the survey ever had produce microscopic self-intersections in overlays. set_precision snaps them to a grid, and in Shapely 2 it guarantees a valid result β but only from valid input:
from shapely import set_precision
bow_tie = cases.set_index("name").geometry["bow-tie"]
try:
set_precision(bow_tie, grid_size=0.001)
except shapely.errors.GEOSException as exc:
print("snap first:", str(exc)[:70])
repaired = shapely.make_valid(bow_tie, method="structure")
snapped = set_precision(repaired, grid_size=0.001)
print("repair, then snap:", snapped.is_valid, snapped.area)
snap first: TopologyException: side location conflict at 1 1. This can occur if th
repair, then snap: True 2.0
So the order is repair, then snap: the snap cannot undo the repair, because its default mode keeps the output valid.
Make validity a pipeline invariant
def assert_valid(gdf, label: str) -> None:
invalid = (~gdf.geometry.is_valid).sum()
empty = gdf.geometry.is_empty.sum()
missing = gdf.geometry.isna().sum()
if invalid or empty or missing:
raise ValueError(f"{label}: {invalid} invalid, {empty} empty, {missing} null geometries")
for label, frame in (("raw OSM", osm), ("after structure repair", gdf)):
try:
assert_valid(frame, label)
print(label, "β ok")
except ValueError as exc:
print(exc)
raw OSM: 19 invalid, 0 empty, 0 null geometries
after structure repair β ok
Checking after each stage tells you which step introduced the problem, which is far more useful than discovering it at the end.
Repairing raw OpenStreetMap multipolygons
The data used on this page
The Isle of Wight extract of OpenStreetMap from Geofabrik (ODbL), read with GDAL's OSM driver. Geofabrik's ready-made shapefiles are already validated β every layer checked clean β so the invalid features only appear in the raw .osm.pbf, which is where they reach most pipelines. The count moves as mappers edit; this run's numbers are printed. Beside it, a handful of small constructed shapes, one per kind of damage, so each repair's behaviour can be seen on something you can check by hand.
from pathlib import Path
import geopandas as gpd
import requests
import shapely
PBF = Path("isle-of-wight-260101.osm.pbf")
if not PBF.exists():
PBF.write_bytes(requests.get(
"https://download.geofabrik.de/europe/united-kingdom/england/isle-of-wight-260101.osm.pbf",
timeout=300).content)
osm = gpd.read_file(PBF, layer="multipolygons")[["osm_id", "name", "landuse", "natural", "geometry"]].to_crs(27700)
cases = gpd.GeoDataFrame({"name": [
"bow-tie", "line", "hole outside shell", "overlapping shells",
"nested holes", "spike", "three-point ring", "NaN coordinate"]},
geometry=[shapely.from_wkt(w) for w in [
"POLYGON((0 0, 2 2, 2 0, 0 2, 0 0))",
"LINESTRING(0 0, 1 1)",
"POLYGON((0 0,4 0,4 4,0 4,0 0),(5 5,6 5,6 6,5 6,5 5))",
"MULTIPOLYGON(((0 0,2 0,2 2,0 2,0 0)),((1 1,3 1,3 3,1 3,1 1)))",
"POLYGON((0 0,10 0,10 10,0 10,0 0),(1 1,9 1,9 9,1 9,1 1),(2 2,3 2,3 3,2 3,2 2))",
"POLYGON((0 0,2 0,2 2,0 2,0 0,-1 1,0 0))",
"POLYGON((0 0,1 1,0 0,0 0))",
"POLYGON((0 0,1 0,NaN 1,0 1,0 0))"]])
print(f"{len(osm):,} multipolygons, {(~osm.is_valid).sum()} invalid; "
f"GEOS {'.'.join(map(str, shapely.geos_version))}, Shapely {shapely.__version__}, GeoPandas {gpd.__version__}")
86,080 multipolygons, 19 invalid; GEOS 3.13.1, Shapely 2.1.2, GeoPandas 1.1.4
Example 1: a repair function with a report
import geopandas as gpd
from shapely.validation import explain_validity
def repair(gdf: gpd.GeoDataFrame) -> tuple[gpd.GeoDataFrame, dict]:
report = {"input": len(gdf)}
invalid_mask = ~gdf.geometry.is_valid
report["invalid_before"] = int(invalid_mask.sum())
report["reasons"] = (
gdf.loc[invalid_mask, "geometry"].apply(explain_validity)
.str.split("[").str[0].value_counts().to_dict()
) if invalid_mask.any() else {}
area_before = float(gdf.geometry.area.sum())
out = gdf.copy()
out.loc[invalid_mask, "geometry"] = out.loc[invalid_mask, "geometry"].make_valid(
method="structure", keep_collapsed=False)
out = out[out.geometry.notna() & ~out.geometry.is_empty]
report.update({
"invalid_after": int((~out.geometry.is_valid).sum()),
"dropped": report["input"] - len(out),
"area_change_pct": round(
(float(out.geometry.area.sum()) - area_before) / area_before * 100, 6
) if area_before else 0.0,
"output": len(out),
})
return out, report
clean, report = repair(osm)
for k, v in report.items():
print(f"{k:18} {v}")
input 86080
invalid_before 19
reasons {'Self-intersection': 19}
invalid_after 0
dropped 0
area_change_pct 0.0
output 86080
An earlier version of this function built the reasons line unconditionally, and crashed on a layer with nothing wrong with it: 293,729 HM Land Registry parcels for Leeds, all valid, gave explain_validity an empty column, and pandas refuses .str on an empty object column. The guard is there for the good days.
Example 2: repair only what needs it
import time
mask = ~osm.geometry.is_valid
valid = osm.geometry[~mask]
for label, fix in (("buffer(0)", lambda g: g.buffer(0)),
("make_valid linework", lambda g: g.make_valid()),
("make_valid structure", lambda g: g.make_valid(method="structure"))):
t = time.perf_counter(); out = fix(osm.geometry); t_all = time.perf_counter() - t
same = int(out[~mask].geom_equals_exact(valid, 0).sum())
print(f"{label:21} all rows {t_all:.2f} s | valid rows untouched {same:6,} of {len(valid):,}"
f" | topologically equal {out[~mask].geom_equals(valid).mean():.2%}")
t = time.perf_counter(); m = ~osm.geometry.is_valid
osm.loc[m, "geometry"].make_valid(method="structure"); t_masked = time.perf_counter() - t
print(f"masked: is_valid plus {int(m.sum())} repairs {t_masked:.2f} s")
buffer(0) all rows 0.23 s | valid rows untouched 6 of 86,061 | topologically equal 100.00%
make_valid linework all rows 0.13 s | valid rows untouched 86,061 of 86,061 | topologically equal 100.00%
make_valid structure all rows 0.48 s | valid rows untouched 0 of 86,061 | topologically equal 100.00%
masked: is_valid plus 19 repairs 0.11 s
The default linework mode hands a valid geometry back exactly as it found it. structure mode and buffer(0) do not: both rebuild the rings of every row, so a valid polygon comes back describing the same shape with different vertex order β equal as a shape, different as data. A layer "cleaned" that way differs from its source on every row, and a diff against the original becomes useless. Masking to the invalid rows keeps the change set to the rows that needed it, and with structure mode it is also the faster path.
Example 3: repairing before an overlay
from shapely import set_precision
def prepare(gdf, grid=0.01):
g = gdf[~gdf.geometry.map(is_degenerate)].copy()
g["geometry"] = g.geometry.make_valid(method="structure", keep_collapsed=False)
g["geometry"] = set_precision(g.geometry.values, grid_size=grid) # after the repair
g = g[g.geometry.notna() & ~g.geometry.is_empty]
return g[g.geom_type.isin(["Polygon", "MultiPolygon"])]
woods = osm[osm["natural"].eq("wood") | osm["landuse"].eq("forest")]
farmland = osm[osm["landuse"].eq("farmland")]
try:
gpd.overlay(woods, farmland, how="intersection")
print("raw overlay ran")
except shapely.errors.GEOSException as exc:
print("raw overlay:", str(exc)[:80])
overlap = gpd.overlay(prepare(woods), prepare(farmland), how="intersection")
print(f"prepared overlay: {len(overlap)} pieces, {overlap.area.sum() / 1e4:,.1f} ha of wood mapped over farmland")
raw overlay ran
prepared overlay: 193 pieces, 146.7 ha of wood mapped over farmland
The sequence is drop degenerates, repair, snap, filter, then overlay. With GEOS's newer overlay engine a raw overlay of slightly invalid input may well run, as it did here; the repair is what makes its answer trustworthy rather than what makes it finish.
Example 4: keep a record of what was changed
record = osm.loc[~osm.geometry.is_valid, ["osm_id", "landuse", "natural", "geometry"]].copy()
record["area_before"] = record.area
record["geometry"] = record.geometry.make_valid() # the default, linework
record["area_after"] = record.area
record["area_delta_pct"] = (record["area_after"] - record["area_before"]) / record["area_before"] * 100
suspicious = record[record["area_delta_pct"].abs() > 0.1]
suspicious.drop(columns="geometry").to_csv("repairs_to_review.csv", index=False)
print(suspicious[["osm_id", "landuse", "natural", "area_delta_pct"]].round(3).to_string(index=False))
osm_id landuse natural area_delta_pct
5977981 NaN wood 0.155
Repairs that change area materially deserve a human look, especially where the geometry drives a payment or an entitlement. The geometry is dropped from the review file because the linework result is a GeometryCollection, which GeoPackage, GeoJSON and FlatGeobuf will store but a shapefile refuses (Geometry type of 'Geometry Collection' not supported in shapefiles) β and which the next tool to read the file may not expect.
Simple, misplaced and merged
- Valid is not the same as simple:
is_simpleconcerns self-intersection in lines. ALineStringthat crosses itself is simple-false but valid. - Repair does not fix CRS problems: Geometries with the wrong CRS are perfectly valid and completely misplaced.
unary_unionis not a repair: given a single bow-tie it returned the bow-tie, still invalid. Given overlapping parts it merges them, and feature identity is lost either way.
Internal links
- How to Fix Invalid Geometries in Python (GeoPandas)
- Shapely TopologyException: Found Non-Noded Intersection (How to Fix)
- How to Snap and Align Geometries to Fix Slivers and Gaps in Python
- How to Remove Null and Empty Geometries in GeoPandas
- The Python GIS Data Cleaning Checklist: From Raw Download to Analysis-Ready
- Shapely Basics: Working with Geometry Objects in Python
FAQ
Why did buffer(0) work for years?
Because most real invalidity is the kind it handles: a ring touching or crossing itself or another ring of the same feature. It repaired all 19 invalid OpenStreetMap multipolygons on the Isle of Wight with their area intact. It fails on the less common shapes β bow-ties, stray holes, lines, NaN coordinates β and it never tells you when it has.
Why does make_valid return a GeometryCollection?
Because the default linework mode returns every piece of linework it found, and a ring that runs along another ring leaves a line behind. Use method="structure" (Shapely 2.1+, passed through by GeoPandas 1.1), or explode and keep the polygonal parts.
How do I know what changed after a repair?
Record area and geometry type before and after and compare. Write out the features whose area changed by more than a threshold; those are the ones a human should look at.
What do I do with geometries that are still invalid after make_valid?
They are usually degenerate β fewer than three distinct points, zero area, or NaN coordinates (which make make_valid raise rather than return). Drop them explicitly and log the count, rather than looking for a stronger repair.
Does repairing geometries fix TopologyException in an overlay?
It removes the invalid input that causes most of them. Snapping with set_precision() afterwards removes the float noise behind the rest β but snap after the repair, because set_precision raises on invalid input.
Should I repair the whole layer or only the invalid rows?
Only the invalid rows. make_valid's default mode leaves valid geometries exactly as they were, but method="structure" and buffer(0) rebuild every ring β the same shapes with different vertex order β so applied to a whole layer they turn a 19-row repair into an 86,080-row change.
Is unary_union a good way to clean geometries?
No. It dissolves everything into one geometry, so feature identity and attributes are lost, and given a single invalid bow-tie it returned it unchanged.