buffer(0) Does Not Fix My Invalid Geometry: What to Do Instead

Problem statement

The classic one-liner has stopped working:

gdf["geometry"] = gdf.geometry.buffer(0)
print(gdf.geometry.is_valid.all())     # still False

Or it "works" and quietly destroys data:

before: 4,812 features, 1,204 invalid
after : 4,812 features, 0 invalid, total area down 11%, 37 features now empty

buffer(0) was never a repair function. It is a side effect of how GEOS builds buffers: the operation re-noded the rings and returned whatever polygonal result fell out. For some self-intersections that result is the fix you wanted. For others it drops a ring, keeps the wrong side of a bow-tie, or returns an empty geometry β€” and it does nothing at all for non-polygonal input.

Common situations where it fails:

  • lines and points: buffer(0) on a LineString returns an empty polygon
  • a bow-tie polygon: one lobe is kept, the other silently discarded
  • rings in the wrong order or duplicated: area changes without warning
  • a hole outside its shell: the hole disappears
  • geometries that are invalid because of NaN or infinite coordinates
  • valid geometries that are merely not simple, which is a different property

Quick answer

Use make_valid and check the result:

  1. find out why each geometry is invalid with shapely.validation.explain_validity
  2. repair with shapely.make_valid() β€” GeoPandas exposes it as GeoSeries.make_valid()
  3. compare geometry type and area before and after; a repair that changes either needs review
  4. keep only the parts you expect with .explode() or .extract_unique_points() filtering
  5. re-check validity and fail the pipeline if anything is still invalid
import geopandas as gpd
from shapely.validation import explain_validity

gdf = gpd.read_file("data/raw/parcels.gpkg")

bad = gdf[~gdf.geometry.is_valid]
print(f"{len(bad)} invalid of {len(gdf)}")
print(bad.geometry.head().apply(explain_validity).to_list())

before_area = gdf.geometry.area.sum()
gdf["geometry"] = gdf.geometry.make_valid()

print("still invalid:", (~gdf.geometry.is_valid).sum())
print(f"area change: {(gdf.geometry.area.sum() - before_area) / before_area:.2%}")
print(gdf.geom_type.value_counts())

make_valid() requires GeoPandas 0.14+ with Shapely 2.x. On older stacks, shapely.validation.make_valid applied through .apply() does the same job.

The repair ladder

Vertical steps from diagnosis through make_valid, explode, filter and re-validate.
Diagnose, repair, inspect, filter, re-check β€” skipping the middle steps is what loses data.

Step-by-step solution

Panels comparing buffer(0) and make_valid across geometry types and outcomes.
Two repairs, two behaviours β€” one of them is defined for the job.

Find out what is actually wrong

is_valid is a boolean; explain_validity is a sentence.

from shapely.validation import explain_validity
import geopandas as gpd

bad = gdf.loc[~gdf.geometry.is_valid].copy()
bad["reason"] = bad.geometry.apply(explain_validity)
print(bad["reason"].str.split("[").str[0].value_counts())

Typical output:

Self-intersection            842
Ring Self-intersection       210
Nested holes                  87
Too few points in geometry    65

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.

Repair with make_valid

import geopandas as gpd

gdf["geometry"] = gdf.geometry.make_valid()

make_valid implements the OGC-defined repair: it decomposes the input into nodes and edges, rebuilds valid components, and returns everything β€” as a GeometryCollection if the result mixes types. Nothing is discarded silently, which is the crucial difference from buffer(0).

Shapely 2.1 adds a structure-preserving mode:

from shapely import make_valid

# 'linework' (default) keeps every edge, possibly changing type
fixed = make_valid(geom)

# 'structure' keeps the polygon structure, closer to a GIS "repair"
fixed = make_valid(geom, method="structure", keep_collapsed=False)

method="structure" is usually what a GIS user expects from a polygon repair: the result stays polygonal, and collapsed slivers can be 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.

before = gdf.geom_type.value_counts()
areas_before = gdf.geometry.area

gdf["geometry"] = gdf.geometry.make_valid()

after = gdf.geom_type.value_counts()
print(gpd.pd.concat([before.rename("before"), after.rename("after")], axis=1).fillna(0))

delta = (gdf.geometry.area - areas_before) / areas_before.replace(0, float("nan"))
print("features with >1% area change:", (delta.abs() > 0.01).sum())
print("empty after repair:", gdf.geometry.is_empty.sum())

A GeometryCollection appearing in the after column means the input contained both polygonal and linear components β€” the repair kept both, and you have to decide which you want.

Keep only the parts you want

from shapely.geometry import Polygon, MultiPolygon
import geopandas as gpd

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])]
    )

gdf["geometry"] = gdf.geometry.map(polygons_only)
gdf = gdf[gdf.geometry.notna() & ~gdf.geometry.is_empty]

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.

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") and geom.area == 0:
        return True                              # zero-area sliver
    return False

drop = gdf.geometry.map(is_degenerate)
print(f"dropping {drop.sum()} degenerate geometries")
gdf = gdf.loc[~drop].copy()

A polygon with three identical points, or one carrying NaN coordinates, cannot become valid. Removing it explicitly β€” and recording how many you removed β€” is the honest fix.

Consider precision reduction for stubborn cases

Geometries that keep failing after a repair often carry coordinates at a precision the source never really had.

from shapely import set_precision

gdf["geometry"] = set_precision(gdf.geometry.values, grid_size=0.001)   # 1 mm grid
gdf["geometry"] = gdf.geometry.make_valid()

Snapping to a grid removes the microscopic self-intersections produced by float noise, and it is the standard remedy before an overlay that keeps throwing TopologyException.

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")

assert_valid(gdf, "after cleaning")

Checking after each stage tells you which step introduced the problem, which is far more useful than discovering it at the end.

Code examples

Example 1: a repair function with a report

import geopandas as gpd
from shapely.validation import explain_validity

def repair(gdf: gpd.GeoDataFrame, keep="polygon") -> 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()
    )
    area_before = float(gdf.geometry.area.sum())

    out = gdf.copy()
    out["geometry"] = out.geometry.make_valid()

    if keep == "polygon":
        out = out.explode(index_parts=False)
        out = out[out.geom_type.isin(["Polygon", "MultiPolygon"])]

    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, 3
        ) if area_before else 0.0,
        "output": len(out),
    })
    return out, report

clean, report = repair(gpd.read_file("data/raw/parcels.gpkg"))
for k, v in report.items():
    print(f"{k:18} {v}")

Example 2: repair only what needs it

Repairing every geometry is wasteful and risks perturbing rows that were fine.

mask = ~gdf.geometry.is_valid
print(f"repairing {mask.sum()} of {len(gdf)}")
gdf.loc[mask, "geometry"] = gdf.loc[mask, "geometry"].make_valid()

On a million-row layer this is the difference between seconds and minutes, and it keeps the diff small.

Example 3: repairing before an overlay that keeps failing

import geopandas as gpd
from shapely import set_precision

def prepare(gdf, grid=0.001):
    g = gdf.copy()
    g["geometry"] = set_precision(g.geometry.values, grid_size=grid)
    g["geometry"] = g.geometry.make_valid()
    g = g[g.geometry.notna() & ~g.geometry.is_empty]
    return g[g.geom_type.isin(["Polygon", "MultiPolygon"])]

parcels = prepare(gpd.read_file("data/raw/parcels.gpkg"))
zones   = prepare(gpd.read_file("data/ref/zones.gpkg").to_crs(parcels.crs))

overlay = gpd.overlay(parcels, zones, how="intersection")
print(len(overlay), "result features")

This sequence β€” snap, repair, filter, then overlay β€” clears the great majority of TopologyException failures.

Example 4: keep a record of what was changed

gdf["was_invalid"] = ~gdf.geometry.is_valid
gdf["area_before"] = gdf.geometry.area
gdf["geometry"] = gdf.geometry.make_valid()
gdf["area_after"] = gdf.geometry.area
gdf["area_delta_pct"] = (gdf["area_after"] - gdf["area_before"]) / gdf["area_before"] * 100

suspicious = gdf[gdf["area_delta_pct"].abs() > 1]
suspicious.to_file("data/out/repairs_to_review.gpkg", driver="GPKG")
print(f"{len(suspicious)} features changed area by more than 1% β€” written for review")

Repairs that change area materially deserve a human look, especially where the geometry drives a payment or an entitlement.

Explanation

A polygon is valid, in the OGC sense, when its rings do not cross themselves or each other, its holes lie inside its shell and do not overlap, and its interior is connected. is_valid tests exactly that. Invalidity is not a corruption of the file β€” the coordinates are all readable β€” it is a statement about topology, and topology is what every overlay operation depends on. That is why an invalid input surfaces as TopologyException in overlay, clip or dissolve rather than at read time.

Three small map panels showing a bow-tie self-intersection, a nested hole, and a repaired result.
Different invalidities need different repairs β€” which is why one trick cannot cover them all.

buffer(0) acquired its reputation because GEOS builds a buffer by re-noding the input's linework and reconstructing polygons from the resulting graph. With a distance of zero the reconstruction often returns a valid version of the same shape. But the operation was never specified to preserve your data: for a bow-tie it keeps one lobe and discards the other, for reversed rings it may swap interior and exterior, and for anything non-polygonal it returns an empty polygon, because a zero-width buffer of a line is empty by definition.

make_valid was written for this job. It decomposes the input into its constituent nodes and edges, rebuilds valid geometries from them, and returns all the resulting components β€” hence the GeometryCollection results that surprise people. That is not a flaw; it is the function telling you the input contained more than one kind of thing. Shapely 2.1's method="structure" offers the alternative most GIS users want, keeping the result polygonal and optionally dropping collapsed pieces.

The habit worth building is not "use make_valid instead of buffer(0)" but "measure the repair". Count invalid features before, count them after, compare geometry types and total area, and write the materially changed features out for review. A repair is an edit to the data, and edits deserve the same scrutiny as any other transformation in the pipeline.

Edge cases or notes

  • make_valid can return a GeometryCollection: Explode and filter to the type you need; many drivers refuse to write mixed collections.
  • GeoSeries.make_valid() needs Shapely 2.x: On older stacks, use gdf.geometry.apply(shapely.validation.make_valid).
  • Valid is not the same as simple: is_simple concerns self-intersection in lines. A LineString that crosses itself is simple-false but valid.
  • Repair does not fix CRS problems: Geometries with the wrong CRS are perfectly valid and completely misplaced.
  • Zero-area slivers survive linework mode: They come back as lines inside a collection. method="structure" with keep_collapsed=False drops them.
  • unary_union is not a repair: It merges geometries and can hide invalidity by dissolving it; feature identity is lost.
  • Ordering matters before an overlay: Snap precision first, then repair. Repairing then snapping can reintroduce invalidity.

FAQ

Why did buffer(0) work for years and stop now?

It never worked reliably β€” it worked for the self-intersections you happened to have. Newer GEOS versions also handle some cases differently. make_valid is defined for the task; buffer(0) is a side effect.

Why does make_valid return a GeometryCollection?

Because the repair produced components of more than one type β€” typically polygons plus the lines left by a collapsed sliver. Explode and keep the polygonal parts, or use method="structure" in Shapely 2.1+.

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. Drop them explicitly and log the count, rather than looking for a stronger repair.

Does repairing geometries fix TopologyException in an overlay?

Usually, especially combined with set_precision() to snap coordinates to a grid first. The exception is caused by nodes that do not line up, which precision reduction addresses directly.

Should I repair the whole layer or only the invalid rows?

Only the invalid rows. It is faster and leaves valid geometries untouched, which keeps the change set small and reviewable.

Is unary_union a good way to clean geometries?

No. It dissolves everything into one geometry, so feature identity and attributes are lost. It can make an overlay succeed while destroying the structure you needed.