Fixing Invalid Geometries Before Buffering

Detect and repair invalid geometries with is_valid, explain_validity, and shapely make_valid or buffer(0) so self-intersections never corrupt buffers or joins.

TL;DR Answer

Invalid polygons — self-intersecting rings, bowties, and rings that touch themselves — cause GEOS to raise TopologyException or, worse, return silently wrong buffers and spatial joins. Detect them with gdf.geometry.is_valid and diagnose each failure with shapely.validation.explain_validity. Repair with shapely.make_valid() (the preferred, area-preserving fix), fall back to the legacy buffer(0) only when you intend to drop slivers, then drop empties and assert gdf.geometry.is_valid.all() before you buffer. This is the mandatory first step of any vector proximity and buffer generation workflow.

from shapely import make_valid

def repair(geom):
    return geom if geom.is_valid else make_valid(geom)

gdf["geometry"] = gdf.geometry.apply(repair)
gdf = gdf[~gdf.geometry.is_empty & gdf.geometry.notna()]
assert gdf.geometry.is_valid.all(), "Repair failed — geometries still invalid"

Part of: Vector Proximity and Buffer Generation


Why Invalid Geometries Break Buffering

A polygon is valid in the OGC Simple Features sense when its rings do not cross themselves, interior rings sit inside the exterior, and the boundary encloses a single connected interior. Real-world vector data violates these rules constantly. Cadastral parcels digitized by hand pick up bowtie self-intersections; polygons exported from CAD software carry duplicate vertices and zero-length segments; simplification routines pull a boundary across itself. None of these problems are visible when you plot the layer — the geometry looks fine on screen but is topologically broken underneath.

The damage surfaces the moment you run a geometric operation. GEOS, the C++ engine behind shapely and geopandas, nodes the polygon boundary into a planar graph before it offsets the edges to build a buffer. A self-intersection makes that graph inconsistent, and GEOS aborts with a TopologyException: found non-noded intersection. That is the lucky outcome, because it fails loudly. The dangerous outcome is silent corruption: an invalid polygon can produce a buffer with the wrong area, an overlay that drops a sliver, or a spatial join that assigns a point to the wrong parcel. The pipeline runs to completion and writes plausible-looking features that are quietly wrong.

For a machine learning workflow this is corrosive. If you are generating proximity features — distance to nearest road, area within a 500 m buffer, count of neighbours — an invalid input geometry corrupts the feature value for that row without raising an error. The model trains on poisoned features and you have no diagnostic trail. Validating and repairing geometries is therefore not defensive housekeeping; it is a prerequisite for correct feature values, in the same category as fixing projection mismatches in pandas GeoDataFrames before you measure any distance.


Detecting Invalid Geometries

geopandas exposes validity through the vectorized is_valid property, and shapely.validation.explain_validity tells you why a specific geometry failed. Always inspect the reasons before repairing — the fix you choose depends on the failure mode.

import geopandas as gpd
from shapely.validation import explain_validity

gdf = gpd.read_file("parcels.gpkg")

invalid = gdf[~gdf.geometry.is_valid]
print(f"{len(invalid)} of {len(gdf)} geometries are invalid")

for idx, geom in invalid.geometry.items():
    print(f"  row {idx}: {explain_validity(geom)}")

Typical output looks like this:

7 of 4212 geometries are invalid
  row 118:  Self-intersection[512340.2 540188.7]
  row 903:  Ring Self-intersection[498210.0 5399820.5]
  row 1550: Self-intersection[503994.1 5402145.9]

The coordinate in each message is the exact point where the boundary crosses itself, which is invaluable when you want to inspect the offending vertex. A plain Self-intersection is a bowtie in the exterior ring; a Ring Self-intersection means an interior (hole) ring touches or crosses the exterior. Both block buffering, and both are repairable.


make_valid() vs buffer(0): Choosing the Repair

There are two established repairs in the shapely ecosystem, and they are not equivalent.

buffer(0) is the old trick. Buffering by a distance of zero forces GEOS to re-run its polygon overlay, which re-nodes the boundary and returns a valid result. It works, and for decades it was the only option. Its flaw is that the overlay discards anything with zero area: dangling edges, spikes, and thin slivers vanish. If a bowtie encloses two lobes of genuine area, buffer(0) may keep only the larger lobe and silently drop the smaller one. You lose area and you are never told.

make_valid() — added to shapely 2.0, wrapping GEOS MakeValid — performs a structured repair that preserves every part of the input. Where a bowtie encloses two lobes, make_valid returns both, potentially as a MultiPolygon, and where lines and points survive the repair it may return a GeometryCollection. Nothing is thrown away. That fidelity is exactly why it is the safer default: you can always simplify a repaired geometry later, but you cannot recover area that buffer(0) already deleted.

The practical rule: reach for make_valid() first. Fall back to buffer(0) only when you want slivers gone — for example, when cleaning up the output of an aggressive simplification and you have confirmed the discarded pieces are digitizing noise, not real land.

The diagram below traces both repair paths for a single self-intersecting parcel.

Repair paths: make_valid preserves area, buffer(0) drops slivers A self-intersecting bowtie polygon feeds two repair branches. The make_valid branch returns both lobes as a MultiPolygon preserving all area. The buffer(0) branch returns only the larger lobe, dropping the smaller sliver. Invalid input Self-intersection is_valid = False make_valid() — preferred MultiPolygon both lobes kept area preserved buffer(0) — legacy Polygon one lobe kept sliver dropped

A Production-Ready repair_geometries() Function

In a real pipeline you want repair to be a single, typed, logged step that reports what it changed. The function below takes a GeoDataFrame, repairs every invalid geometry, drops empties and nulls, and returns both the cleaned frame and a structured report you can log or assert against.

import logging
from dataclasses import dataclass, field

import geopandas as gpd
from shapely import make_valid
from shapely.validation import explain_validity

logger = logging.getLogger(__name__)


@dataclass
class ValidityReport:
    """Summary of a repair pass over a GeoDataFrame."""
    n_input: int
    n_invalid: int
    n_repaired: int
    n_dropped_empty: int
    n_dropped_null: int
    reasons: dict[str, int] = field(default_factory=dict)


def repair_geometries(
    gdf: gpd.GeoDataFrame,
    method: str = "make_valid",
) -> tuple[gpd.GeoDataFrame, ValidityReport]:
    """Repair invalid geometries and drop empties, returning a validity report.

    Parameters
    ----------
    gdf : gpd.GeoDataFrame
        Input layer. A copy is made; the original is not mutated.
    method : {"make_valid", "buffer0"}, default="make_valid"
        Repair strategy. "make_valid" preserves all area (preferred).
        "buffer0" runs buffer(0), which drops zero-area slivers.

    Returns
    -------
    (gdf_clean, report) : tuple[gpd.GeoDataFrame, ValidityReport]
        A new GeoDataFrame with only valid, non-empty geometries and a
        ValidityReport describing what changed.
    """
    if method not in {"make_valid", "buffer0"}:
        raise ValueError(f"Unknown method {method!r}; use 'make_valid' or 'buffer0'.")
    if gdf.geometry.name is None:
        raise ValueError("GeoDataFrame has no active geometry column.")

    gdf = gdf.copy()
    n_input = len(gdf)

    # Drop rows with null geometry first — they cannot be repaired.
    null_mask = gdf.geometry.isna()
    n_dropped_null = int(null_mask.sum())
    if n_dropped_null:
        logger.warning("Dropping %d rows with null geometry", n_dropped_null)
    gdf = gdf[~null_mask]

    invalid_mask = ~gdf.geometry.is_valid
    n_invalid = int(invalid_mask.sum())

    reasons: dict[str, int] = {}
    for geom in gdf.geometry[invalid_mask]:
        # Keep only the reason keyword, not the coordinate, for the tally.
        reason = explain_validity(geom).split("[")[0].strip()
        reasons[reason] = reasons.get(reason, 0) + 1

    if n_invalid:
        if method == "make_valid":
            gdf.loc[invalid_mask, gdf.geometry.name] = (
                gdf.geometry[invalid_mask].apply(make_valid)
            )
        else:  # buffer0
            gdf.loc[invalid_mask, gdf.geometry.name] = (
                gdf.geometry[invalid_mask].buffer(0)
            )
        logger.info("Repaired %d invalid geometries via %s", n_invalid, method)

    # make_valid / buffer(0) can collapse degenerate polygons to empty.
    empty_mask = gdf.geometry.is_empty
    n_dropped_empty = int(empty_mask.sum())
    if n_dropped_empty:
        logger.warning("Dropping %d geometries that repaired to empty", n_dropped_empty)
    gdf = gdf[~empty_mask]

    report = ValidityReport(
        n_input=n_input,
        n_invalid=n_invalid,
        n_repaired=n_invalid,
        n_dropped_empty=n_dropped_empty,
        n_dropped_null=n_dropped_null,
        reasons=reasons,
    )

    if not gdf.geometry.is_valid.all():
        raise RuntimeError("Repair incomplete — some geometries are still invalid.")

    return gdf, report

The function fails fast on a missing geometry column, tallies failure reasons for observability, and raises if the repair leaves anything invalid so a broken layer can never leak downstream.


Walkthrough on a GeoDataFrame of Parcels

1. Load the layer and confirm the problem exists

import geopandas as gpd

parcels = gpd.read_file("parcels.gpkg").to_crs("EPSG:32633")  # metric CRS
print(f"{(~parcels.geometry.is_valid).sum()} invalid geometries")
# -> 7 invalid geometries

Reproject to a metric CRS first. Buffer distances are meaningless in degrees, and you want the repaired layer already projected for the proximity work that follows.

2. Run the repair and inspect the report

clean, report = repair_geometries(parcels, method="make_valid")

print(report.n_invalid, "repaired")     # 7 repaired
print(report.reasons)
# {'Self-intersection': 5, 'Ring Self-intersection': 2}
print(report.n_dropped_empty)           # 0
print(len(parcels), "->", len(clean))   # 4212 -> 4212

Every invalid parcel was repaired, nothing collapsed to empty, and the row count is unchanged because make_valid preserved all area.

3. Buffer safely now that geometries are valid

clean["geometry"] = clean.geometry.buffer(250)   # 250 m proximity zone
print(clean.geometry.is_valid.all())             # True — no TopologyException

The same buffer(250) call would have raised TopologyException on the raw layer. With valid inputs it succeeds, and the resulting 250 m zones are ready to feed into a distance or overlap feature — see creating distance matrices for spatial features for the next step, or computing zonal statistics with rasterstats if you plan to summarise raster values inside each buffered polygon.


Verification

Never assume a repair succeeded — assert it. The single most important check is that every geometry is valid and non-empty, because make_valid can collapse a degenerate polygon to an empty geometry that still returns True from is_valid.

def assert_buffer_ready(gdf: gpd.GeoDataFrame) -> None:
    """Raise unless every geometry is valid, non-empty, and non-null."""
    assert gdf.geometry.notna().all(),   "Null geometries present"
    assert gdf.geometry.is_valid.all(),  "Invalid geometries remain"
    assert (~gdf.geometry.is_empty).all(), "Empty geometries present"

assert_buffer_ready(clean)
print("All geometries are buffer-ready.")

Wire this assertion in immediately before any buffering, overlay, or spatial join. It turns a whole class of silent, hard-to-trace corruption into a loud failure at the exact step that introduced it, which is precisely where you want the pipeline to stop.


FAQ

Should I use make_valid() or buffer(0) to repair geometries?

Prefer make_valid(). It applies GEOS’s structured repair, preserving all rings and returning a geometry — sometimes a MultiPolygon or GeometryCollection — that keeps every part of the input area. buffer(0) is a legacy trick that resolves self-intersections by re-running the polygon overlay, but it silently deletes zero-area slivers, dangling edges, and degenerate rings. Use buffer(0) only when you deliberately want those artifacts removed and have confirmed no meaningful area is lost.

Why does buffering an invalid polygon raise a TopologyException?

GEOS builds an internal noded graph of the polygon boundary before offsetting it to create the buffer. A self-intersection, or a ring that touches itself, produces an inconsistent graph that the buffer algorithm cannot node cleanly, so GEOS aborts with a TopologyException. Repairing the geometry first removes the self-intersection, giving GEOS a well-formed boundary it can offset without error.

How do I confirm every geometry is valid after repair?

Call gdf.geometry.is_valid.all() — it must return True. Also drop empty and null geometries using is_empty and notna, because make_valid can collapse a degenerate polygon to an empty geometry that passes is_valid but breaks downstream buffers and joins. Asserting both validity and non-emptiness gives you a hard guarantee before any spatial operation runs.


Part of: Vector Proximity and Buffer GenerationSpatial Feature Engineering for Machine Learning