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.
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, reportThe 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 geometriesReproject 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 -> 4212Every 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 TopologyExceptionThe 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.
Related
- Creating Distance Matrices for Spatial Features
- Fixing Projection Mismatches in pandas GeoDataFrames
- Computing Zonal Statistics with rasterstats
- Vector Proximity and Buffer Generation
Part of: Vector Proximity and Buffer Generation — Spatial Feature Engineering for Machine Learning