Validating and Fixing Invalid Geometries in GeoPandas
TL;DR: Diagnose with gdf.geometry.is_valid and name the failure with gdf.geometry.is_valid_reason(), which gives you the class and the offending coordinate. Repair with shapely.make_valid(geom, method="structure", keep_collapsed=False), not buffer(0) — on a bow-tie polygon buffer(0) returns half the area and says nothing. Then assert validity inside the reader function.
Why This Matters
Invalid geometry is the most expensive kind of bad data because it does not fail where it is created. A self-intersecting parcel loaded from a shapefile sails through the read, survives a reprojection, and then produces TopologyException: found non-noded intersection in an overlay eight steps later, in a function that has nothing wrong with it. The traceback names the innocent operation, not the guilty polygon, and the coordinate it prints is in whatever coordinate reference system happened to be active at the time. Half a day disappears into bisecting the pipeline.
The fix is to move detection to the boundary. Every reader that produces a GeoDataFrame for downstream work should test validity and refuse to return a layer that fails, exactly as you would validate a schema. This page sits inside GeoPandas Data Preparation, which is the first stage of the broader Python Workflows for Spatial Modeling & Regression, and it is deliberately the first thing in that stage: validity is a precondition for the reprojection, the joins and the contiguity builds that follow. A single bow-tie polygon is enough to make libpysal.weights.Queen.from_dataframe raise, or worse, to make it quietly produce an island.
The Four Failures That Actually Occur
The OGC simple-features rules for a polygon are short: the exterior ring must be a closed, simple LinearRing; interior rings must lie inside the exterior; rings may touch at points but must not cross; and the interior must be a connected point set. In a decade of production shapefiles, four failures account for nearly everything you will meet, and only three of them are detected by is_valid.
Self-intersection (the bow-tie). A ring crosses itself, usually because a digitiser clicked vertices out of order or a coordinate was transcribed with two digits swapped. GEOS reports Self-intersection with the crossing coordinate. The trap is that a symmetric bow-tie has an area of exactly zero — the shoelace formula gives the two lobes opposite orientation, so they cancel — and an area filter looking for degenerate parcels will happily flag it, while a naive sum over the column will not.
A ring that is not closed. This one never reaches is_valid. Shapely refuses to construct an open LinearRing at all, so a malformed WKT or GeoJSON raises shapely.errors.GEOSException: IllegalArgumentException: Points of LinearRing do not form a closed linestring while the file is being parsed. If you write your own WKT, close the ring; if you build a Polygon from a coordinate list, shapely closes it for you, which is why this failure is almost always an interchange-format problem rather than a Python one.
An interior ring outside its exterior. Reported as Hole lies outside shell. This is the failure that most punishes a blind repair, because there is no topologically sensible answer: the hole is real data that has been mispositioned, and any automatic fix either deletes it or promotes it to a separate polygon. Repairing it without looking will change the total area of your layer.
Duplicate consecutive vertices. These do not make a polygon invalid — GEOS tolerates repeated points and is_valid_reason() will cheerfully return Valid Geometry. They still break things: zero-length segments confuse overlay noding, inflate file size, and turn into Too few points in geometry component the moment a simplification step removes the surviving distinct vertices. shapely.remove_repeated_points is the explicit cleaner.
Environment and Version Pinning
The method argument to make_valid needs shapely 2.1 or later; GeoSeries.is_valid_reason() needs geopandas 1.0 or later. Both are worth having, and both are pinned below.
pip install "geopandas>=1.0.1" "shapely>=2.1.0" "pyogrio>=0.9.0" \
"pyproj>=3.6.1" "numpy>=1.26" "pandas>=2.2"
import geopandas as gpd
import pandas as pd
import shapely
from shapely.geometry import Polygon
from shapely.validation import explain_validity, make_valid
Step-by-Step Implementation
1. Build a layer that contains every failure
Real broken data is hard to publish, so construct the same failures deliberately. The coordinates are in metres and the layer is tagged EPSG:27700 so the areas below are square metres.
bowtie = Polygon([(20, 0), (30, 10), (30, 0), (20, 10)]) # crosses at (25, 5)
hole_out = Polygon([(40, 0), (50, 0), (50, 10), (40, 10)],
[[(52, 2), (58, 2), (58, 8), (52, 8)]]) # hole is off to the side
pinched = Polygon([(60, 0), (70, 0), (65, 5), (70, 10), (60, 10), (65, 5)])
dupes = Polygon([(80, 0), (85, 0), (85, 0), (90, 0), (90, 10), (80, 10)])
gdf = gpd.GeoDataFrame(
{"id": [1, 2, 3, 4, 5, 6, 7]},
geometry=[
Polygon([(0, 0), (0, 10), (10, 10), (10, 0)]), # 1 clean square
bowtie, # 2 self-intersection
hole_out, # 3 hole outside shell
pinched, # 4 ring touches itself
dupes, # 5 repeated vertex, still valid
None, # 6 missing
Polygon(), # 7 empty
],
crs="EPSG:27700",
)
2. Count the failures, separating missing from invalid
is_valid returns False for a missing geometry, which conflates two very different problems. Always test the three predicates together.
geom = gdf.geometry
report = {
"rows": len(gdf),
"valid": int(geom.is_valid.sum()),
"invalid": int((~geom.is_valid & geom.notna()).sum()),
"missing": int(geom.isna().sum()),
"empty": int(geom.is_empty.sum()),
}
for k, v in report.items():
print(f"{k:>8}: {v}")
rows: 7
valid: 3
invalid: 3
missing: 1
empty: 1
Seven rows, three of which pass is_valid: the clean square, the polygon with duplicate vertices, and — surprisingly — the empty polygon, since an empty geometry is trivially valid. The False count is four, but only three of those are genuine invalidity.
3. Name the reason and the coordinate
is_valid_reason() is the vectorised form of shapely.validation.explain_validity. It returns "Valid Geometry" for good rows and None for missing ones.
reasons = gdf.geometry.is_valid_reason()
bad = reasons[reasons != "Valid Geometry"]
print(pd.DataFrame({"id": gdf.loc[bad.index, "id"], "reason": bad}).to_string(index=False))
id reason
2 Self-intersection[25 5]
3 Hole lies outside shell[52 2]
4 Ring Self-intersection[65 5]
6 None
The bracketed pair is the coordinate where GEOS gave up, in the layer’s own units. Feed it straight back into a viewer, or filter the layer to a small window around it, and the digitising error is usually obvious. Note the distinction GEOS draws between Self-intersection, where two ring segments cross, and Ring Self-intersection, where a ring merely touches itself at a point — the pinched hourglass in row 4.
4. Repair with make_valid, and measure what it did
make_valid has two algorithms. The default "linework" preserves every input edge and can therefore hand back a GeometryCollection; "structure" interprets rings by their role as shell or hole and always returns a polygonal result.
targets = gdf[~gdf.geometry.is_valid & gdf.geometry.notna()]
rows = []
for _, r in targets.iterrows():
lw = make_valid(r.geometry) # method="linework"
st = make_valid(r.geometry, method="structure", keep_collapsed=False)
rows.append({
"id": r["id"],
"before_area": r.geometry.area,
"linework_type": lw.geom_type,
"linework_area": lw.area,
"structure_type": st.geom_type,
"structure_area": st.area,
})
print(pd.DataFrame(rows).to_string(index=False, float_format=lambda v: f"{v:.2f}"))
id before_area linework_type linework_area structure_type structure_area
2 0.00 MultiPolygon 50.00 MultiPolygon 50.00
3 64.00 MultiPolygon 136.00 Polygon 100.00
4 50.00 MultiPolygon 50.00 MultiPolygon 50.00
Row 2 is the headline: a bow-tie whose two 25 m² lobes have opposite orientation reports an area of 0.00, and the repair recovers all 50.00. Row 3 is the warning: the invalid polygon reported 64.00 (a 100 m² shell minus a 36 m² hole that was never inside it), "linework" turned the stray hole into a second polygon for 136.00, and "structure" discarded it for 100.00. Three different numbers, none of them the truth, because the truth is a hole whose coordinates were mistyped.
5. Never reach for buffer(0)
The old trick works by running the polygon through the buffering algorithm with a zero distance, which happens to re-node the edges. It is fast and it is lossy.
comparison = []
for gid in (2, 4):
g = gdf.loc[gdf["id"] == gid, "geometry"].iloc[0]
mv, b0 = make_valid(g), g.buffer(0)
comparison.append({"id": gid,
"make_valid": f"{mv.geom_type} ({len(shapely.get_parts(mv))})",
"mv_area": mv.area,
"buffer0": f"{b0.geom_type} ({len(shapely.get_parts(b0))})",
"b0_area": b0.area})
print(pd.DataFrame(comparison).to_string(index=False, float_format=lambda v: f"{v:.2f}"))
id make_valid mv_area buffer0 b0_area
2 MultiPolygon (2) 50.00 Polygon (1) 25.00
4 MultiPolygon (2) 50.00 MultiPolygon (2) 50.00
On the pinched polygon the two agree. On the bow-tie, buffer(0) keeps whichever lobe carries the ring’s dominant orientation and discards the other — 25 m² of parcel gone, no warning, no exception, and a result that passes every validity check you subsequently run. This is precisely the failure mode that makes a land-area total quietly wrong by a few per cent.
6. Triage the GeometryCollection
The "linework" method returns a collection whenever part of the input collapses to a line. A square with a zero-width spike is the canonical case.
spiked = Polygon([(0, 0), (10, 0), (15, 5), (10, 0), (10, 10), (0, 10)])
fixed = make_valid(spiked)
print(fixed.geom_type, len(shapely.get_parts(fixed)))
for part in shapely.get_parts(fixed):
print(f" {part.geom_type:<12} area={part.area:.2f} length={part.length:.2f}")
GeometryCollection 2
Polygon area=100.00 length=40.00
LineString area=0.00 length=7.07
The spike out to (15, 5) and back has no width, so it survives as a LineString of length √50 ≈ 7.07. Any downstream code that assumes a polygonal column will now fail. Two ways out — pick the algorithm that cannot produce a collection, or explode and filter:
def polygonal_only(gdf, id_col="id"):
"""Repair, then keep only the polygonal parts, one row per input feature."""
out = gdf.copy()
out["geometry"] = out.geometry.make_valid()
out = out.explode(index_parts=False, ignore_index=True)
out = out[out.geom_type.isin(["Polygon", "MultiPolygon"])]
return out.dissolve(by=id_col, as_index=False)
print(make_valid(spiked, method="structure", keep_collapsed=False).geom_type)
Polygon
Prefer method="structure" when you want one geometry back and are content to lose the collapsed remnants. Use the explode-and-filter route when you need to log what was discarded, which is the right choice in a regulated pipeline.
7. Put the check in the reader
The last step is the one that pays for all the others. Wrap the validity test in the function that loads the layer, so that a broken file fails at ingest with a message naming the rows.
class GeometryError(ValueError):
pass
def read_clean(path, layer_name, allowed=("Polygon", "MultiPolygon"), repair=True):
gdf = gpd.read_file(path, engine="pyogrio")
if repair:
keep = gdf.geometry.notna() & ~gdf.geometry.is_valid
gdf.loc[keep, "geometry"] = gdf.loc[keep, "geometry"].make_valid(
method="structure", keep_collapsed=False
)
missing = gdf.index[gdf.geometry.isna()]
empty = gdf.index[gdf.geometry.is_empty]
invalid = gdf.index[~gdf.geometry.is_valid & gdf.geometry.notna()]
wrong = gdf.index[~gdf.geom_type.isin(allowed) & gdf.geometry.notna()]
if len(missing) or len(empty) or len(invalid) or len(wrong):
raise GeometryError(
f"{layer_name}: {len(invalid)} invalid, {len(missing)} missing, "
f"{len(empty)} empty, {len(wrong)} wrong type; "
f"first offenders {sorted(set(missing) | set(empty) | set(invalid) | set(wrong))[:5]}"
)
return gdf
GeometryError: parcels: 0 invalid, 1 missing, 1 empty, 0 wrong type;
first offenders [5, 6]
Note that repair=True fixes only the genuinely invalid rows and leaves the missing and empty ones to trip the assertion. That is deliberate: a None geometry is a data-supply problem, and silently dropping it changes your row count without telling anyone.
Interpreting the Output
Read the diagnostic in the order the gates run. A missing count above zero means the file, the driver or an upstream join produced null geometry — no repair function will help, and dropping the rows silently is how row counts drift between stages. An empty count above zero is subtler, because those rows pass is_valid and pass a notna() check; they then vanish from every spatial predicate, so a join that returns fewer matches than expected is often empty geometry rather than a genuine absence of overlap.
For genuine invalidity, the number that matters is not whether the repair succeeded but by how much the area moved. A repair that leaves total area unchanged to within floating-point noise is almost certainly correct: it re-noded edges without inventing or destroying anything. A repair that changes total area by more than a fraction of a per cent has made a decision on your behalf, and you need to know which rows it made it on. The bow-tie moving from 0.00 to 50.00 is a good change; the hole-outside-shell moving from 64.00 to 136.00 is not, and only the per-row comparison distinguishes them.
Good looks like this: zero missing, zero empty, zero invalid after repair, one geometry type in the column, and a total-area delta that is either zero or fully accounted for row by row. The warning signs are a repaired layer whose geom_type column now contains GeometryCollection, a row count that changed during cleaning, and an area total that moved without an explanation attached to specific feature identifiers.
Critical Best Practices
Validate before reprojecting, not after
A transformation moves every vertex, so a self-intersection at one coordinate becomes a self-intersection at a different one, and the coordinate in the explain_validity message no longer matches anything in the source file. Worse, reprojection can create invalidity in polygons that cross a projection’s discontinuity, and it can hide it by nudging two coincident points apart. Check in the source coordinate reference system, repair there, then follow the guidance in reprojecting CRS for accurate distance calculations, and check once more afterwards.
Repair only the rows that need it
Running make_valid across an entire column is tempting and wasteful: it rebuilds every geometry, costs time proportional to total vertex count, and can perturb valid geometries through the noding pass. Mask first with gdf.geometry.notna() & ~gdf.geometry.is_valid, repair the subset, and assign back by index. On a million-row layer with forty broken rows this is the difference between a minute and a millisecond.
Snap to a precision grid instead of chasing individual failures
When a layer produces new invalidity every time you touch it, the underlying problem is usually coordinate precision — vertices that are 10⁻¹⁰ apart and are treated as coincident by one operation and distinct by the next. shapely.set_precision(geom, grid_size=0.001) snaps everything to a millimetre grid, removes duplicate consecutive vertices as a side effect, and makes subsequent overlays deterministic. Choose the grid size from your survey accuracy, not from the number of decimal places in the file.
Watch the geometry type after every repair
make_valid under the default "linework" method can change a Polygon into a MultiPolygon or a GeometryCollection, and explode can change your row count. Assert on gdf.geom_type.unique() immediately after the repair rather than discovering the change when a dissolve or an sjoin behaves oddly. Mixed geometry types in one column are legal in GeoPandas and illegal in most file formats, so the error may not appear until you write the layer out.
Do the validity pass before any join or weights build
Both of the operations that consume a cleaned layer are intolerant of bad topology. Overlay-based joins raise TopologyException on non-noded intersections — the reason the ordering advice in optimizing GeoPandas spatial joins for large datasets starts from a valid layer — and contiguity construction for spatial weight matrices treats an empty or degenerate polygon as an isolate, which propagates into every autocorrelation statistic computed from those weights.
Troubleshooting
| Symptom | Likely cause | Fix |
|---|---|---|
TopologyException: found non-noded intersection during overlay or sjoin |
Self-intersecting input that was never validated | Repair both layers with make_valid first, then shapely.set_precision(g, 0.001) if it recurs |
is_valid is False but is_valid_reason() returns None |
The geometry is missing, not invalid | Test isna() separately; decide whether to drop or quarantine the row |
| Total area changes after cleaning | buffer(0) dropped a lobe, or "linework" promoted a stray hole to a polygon |
Compare area per row before and after; re-digitise any row whose area moved |
ValueError writing to a shapefile after repair |
The column now mixes Polygon and GeometryCollection |
Use method="structure", or explode and keep only polygonal parts |
AttributeError: 'GeoSeries' object has no attribute 'is_valid_reason' |
geopandas older than 1.0 | Upgrade, or fall back to gdf.geometry.apply(explain_validity) |
| Contiguity weights report unexpected isolates | Empty geometries survived the clean | Filter with ~gdf.geometry.is_empty before building weights |
Too few points in geometry component appears only after simplify |
Duplicate vertices masked a ring with under four distinct points | Run shapely.remove_repeated_points first, then drop rings that collapse |
Next Steps
With a valid, single-typed, non-empty layer in hand, move on to reprojecting CRS for accurate distance calculations before any metric work, then to optimizing GeoPandas spatial joins for large datasets, which assumes the validity gate above has already run.
Frequently Asked Questions
Should I use make_valid or buffer(0) to repair invalid polygons?
Use make_valid. It is a documented repair with defined semantics, whereas buffer(0) is a side effect of the buffering algorithm that happens to return a valid result. On a bow-tie polygon of two equal lobes, make_valid returns a MultiPolygon holding both, while buffer(0) returns a single Polygon with half the area and no warning. The only argument for buffer(0) was speed on old GEOS builds, and that gap has closed. Always compare areas before and after any repair.
Why does make_valid return a GeometryCollection?
The default linework method preserves every input edge, so a polygon with a zero-width spike repairs into a Polygon plus the LineString that the spike collapsed to, wrapped in a GeometryCollection. That breaks any code expecting a polygonal column. Either pass method="structure" with keep_collapsed=False, which discards the lineal remnants and returns a Polygon, or explode the collection, keep the parts whose geom_type is Polygon or MultiPolygon, and dissolve them back into one row.
Do duplicate consecutive vertices make a polygon invalid?
Usually not. GEOS tolerates repeated points, so is_valid returns True and is_valid_reason reports Valid Geometry, yet the zero-length segments they create cause trouble in overlays, buffering and simplification, and they inflate memory. They also push a ring towards the too-few-distinct-points failure once a simplify step removes the survivors. Clean them explicitly with shapely.remove_repeated_points, or snap the whole layer to a grid with shapely.set_precision, which removes them as a side effect.
How should missing and empty geometries be handled in a GeoDataFrame?
Treat them as three separate cases. A missing geometry is None and reports is_valid as False even though nothing is wrong with it structurally. An empty geometry reports is_valid as True but has no interior and will silently drop out of every spatial join and contiguity build. A valid non-empty geometry is the only acceptable state downstream. Test with isna, is_empty and is_valid separately, log the counts, and decide per layer whether to drop or quarantine.
Related
- Optimizing GeoPandas Spatial Joins for Large Datasets — the operation that fails loudest on invalid input
- Reprojecting CRS for Accurate Distance Calculations — validate first, transform second, then validate again
- Spatial Weight Matrices — where an empty or degenerate polygon turns into a silent isolate
← Back to GeoPandas Data Preparation