How to Fix Invalid Geometries in Python (GeoPandas)
On this page
Problem statement
The advice for invalid geometries was written when they crashed things: an overlay raised TopologyException, so you found the bad rows and fixed them. With a current GEOS that is rarer. On the Isle of Wight's OpenStreetMap polygons, 19 of 86,080 are invalid, and dissolve, overlay and clip all ran on them without an error. What goes wrong now is quieter: a repair can add or drop area, change every repaired feature's geometry type, and produce output that a shapefile refuses to store or a type filter silently deletes.
This page finds those 19, explains why each is invalid, repairs them two ways, and measures what each repair changed.
Quick answer
Find the invalid rows, repair only those, and check the type as well as the validity:
import geopandas as gpd
from shapely import make_valid
gdf = gpd.read_file("data/iow_polygons.gpkg")
invalid_mask = ~gdf.geometry.is_valid
gdf.loc[invalid_mask, "geometry"] = gdf.loc[invalid_mask, "geometry"].apply(make_valid)
gdf = gdf[gdf.geometry.notna()]
gdf = gdf[~gdf.geometry.is_empty]
gdf = gdf[gdf.geometry.is_valid]
gdf.to_file("output/iow_cleaned.gpkg", driver="GPKG")
print(len(gdf), "features written;", gdf.geom_type.value_counts().to_dict())
86080 features written; {'MultiPolygon': 86061, 'GeometryCollection': 19}
Use make_valid() first. Use buffer(0) only as a fallback for simple polygon issues. Always re-check validity and type before saving: every one of the 19 repaired features here came back as a GeometryCollection.
Repair pipeline
Find, explain, repair and verify
Load the vector dataset into GeoPandas
import geopandas as gpd
gdf = gpd.read_file("data/iow_polygons.gpkg")
print(gdf.crs)
print(gdf.geometry.name)
print(len(gdf))
EPSG:27700
geometry
86080
This works the same way for GeoJSON:
gdf = gpd.read_file("data/parcels.geojson")
Find invalid geometries
Use .is_valid on the geometry column to identify invalid features.
gdf["is_valid"] = gdf.geometry.is_valid
invalid_gdf = gdf[~gdf["is_valid"]]
print("Total features:", len(gdf))
print("Invalid features:", len(invalid_gdf))
Total features: 86080
Invalid features: 19
If you only want the bad rows for review:
print(invalid_gdf[["osm_id", "kind", "is_valid"]].head())
osm_id kind is_valid
1 5922 landuse False
52 1709739 landuse False
156 5977981 natural False
206 6050307 landuse False
337 6061972 landuse False
Check why each geometry is invalid
explain_validity() says what is wrong and where:
from shapely.validation import explain_validity
invalid_gdf = invalid_gdf.copy()
invalid_gdf["validity_reason"] = invalid_gdf.geometry.apply(explain_validity)
print(invalid_gdf[["validity_reason"]].head(5))
print(invalid_gdf["validity_reason"].str.split("[").str[0].value_counts().to_dict())
validity_reason
1 Self-intersection[458382.951875285 92054.06993...
52 Self-intersection[458626.186985012 82399.79050...
156 Self-intersection[454466.763903775 85739.66413...
206 Self-intersection[443751.136953349 88697.80677...
337 Self-intersection[453792.578786255 90676.18743...
{'Self-intersection': 19}
Repair geometries with make_valid()
The preferred repair method is make_valid() from Shapely.
from shapely import make_valid
Apply it only to invalid rows:
gdf_repaired = gdf.copy()
invalid_mask = ~gdf_repaired.geometry.is_valid
gdf_repaired.loc[invalid_mask, "geometry"] = (
gdf_repaired.loc[invalid_mask, "geometry"].apply(make_valid)
)
Then check the result:
gdf_repaired["is_valid_after"] = gdf_repaired.geometry.is_valid
print("Still invalid:", (~gdf_repaired["is_valid_after"]).sum())
print(gdf_repaired.geometry.geom_type.value_counts())
Still invalid: 0
MultiPolygon 86061
GeometryCollection 19
Name: count, dtype: int64
One argument matters. The default, method="linework", keeps every line the input drew, so a self-intersecting polygon comes back as polygons plus stray line segments in a GeometryCollection. method="structure" rebuilds the polygons from their rings and returns Polygon or MultiPolygon:
repaired = gdf.loc[invalid_mask, "geometry"]
for method in ("linework", "structure"):
fixed = repaired.make_valid(method=method)
print(f"{method:9} {fixed.geom_type.value_counts().to_dict()}, "
f"area change {fixed.area.sum() - repaired.area.sum():+,.0f} mΒ²")
print(f"buffer(0) {repaired.buffer(0).geom_type.value_counts().to_dict()}, "
f"area change {repaired.buffer(0).area.sum() - repaired.area.sum():+,.0f} mΒ²")
linework {'GeometryCollection': 19}, area change +117 mΒ²
structure {'Polygon': 16, 'MultiPolygon': 3}, area change +0 mΒ²
buffer(0) {'Polygon': 16, 'MultiPolygon': 3}, area change +0 mΒ²
For polygon layers, make_valid(method="structure") is usually what you meant. Linework mode's extra area is a self-intersection read as more polygon rather than less.
Use buffer(0) as a fallback for simple polygon issues
The older trick:
gdf_buffered = gdf.copy()
invalid_mask = ~gdf_buffered.geometry.is_valid
gdf_buffered.loc[invalid_mask, "geometry"] = (
gdf_buffered.loc[invalid_mask, "geometry"].buffer(0)
)
Re-check validity:
print("Still invalid after buffer(0):", (~gdf_buffered.geometry.is_valid).sum())
Still invalid after buffer(0): 0
Use this carefully. Here it agreed with structure mode on all 19 features, but it is known to drop whole lobes of a polygon in other cases. It is not the best default repair method.
Remove empty or still-invalid results
cleaned = gdf_repaired.copy()
cleaned = cleaned[cleaned.geometry.notna()]
cleaned = cleaned[~cleaned.geometry.is_empty]
cleaned = cleaned[cleaned.geometry.is_valid]
print("Remaining features:", len(cleaned))
Remaining features: 86080
If downstream tools require polygon-only output, inspect geometry types after repair:
print(cleaned.geometry.geom_type.value_counts())
MultiPolygon 86061
GeometryCollection 19
Name: count, dtype: int64
A simple polygon-only filter looks like this β and on this data it would be a mistake:
polygon_types = ["Polygon", "MultiPolygon"]
cleaned_polygons = cleaned[cleaned.geometry.geom_type.isin(polygon_types)].copy()
print(len(cleaned) - len(cleaned_polygons), "features dropped by the type filter")
19 features dropped by the type filter
Every repaired feature was a GeometryCollection whose polygons were the feature itself, so filtering by type deletes all 19. Keep the polygonal parts instead:
import shapely
from shapely.geometry import MultiPolygon
def polygon_parts(geom):
parts = [p for p in shapely.get_parts(geom) if p.geom_type in ("Polygon", "MultiPolygon")]
return shapely.union_all(parts) if parts else None
cleaned_polygons = cleaned.copy()
cleaned_polygons["geometry"] = cleaned_polygons.geometry.map(polygon_parts)
print(cleaned_polygons.geom_type.value_counts().to_dict())
{'Polygon': 86027, 'MultiPolygon': 53}
Verify repaired geometries before continuing
print("Original feature count:", len(gdf))
print("Cleaned feature count:", len(cleaned))
print("Invalid after repair:", (~cleaned.geometry.is_valid).sum())
print(cleaned.geometry.geom_type.value_counts())
Original feature count: 86080
Cleaned feature count: 86080
Invalid after repair: 0
MultiPolygon 86061
GeometryCollection 19
Name: count, dtype: int64
This matters because make_valid() may split one invalid polygon into multiple valid parts or return a different geometry type.
Save the cleaned dataset
cleaned.to_file("output/iow_cleaned.gpkg", driver="GPKG")
print(gpd.read_file("output/iow_cleaned.gpkg").geom_type.value_counts().to_dict())
{'MultiPolygon': 86061, 'GeometryCollection': 19}
You can also save to GeoJSON:
cleaned.to_file("output/iow_cleaned.geojson", driver="GeoJSON")
If you save to shapefile, be aware that it is more restrictive:
try:
cleaned.to_file("output/iow_cleaned.shp")
except Exception as error:
print(type(error).__name__ + ":", str(error)[:110])
cleaned_polygons.to_file("output/iow_cleaned.shp")
print("polygon parts only:", len(gpd.read_file("output/iow_cleaned.shp")), "features written")
FeatureError: Could not add feature to layer at index 1: Attempt to write non-polygon (MULTILINESTRING) geometry to POLYGON
polygon parts only: 86080 features written
A layer of mixed types β which a linework repair produces β needs its polygonal parts extracted before a shapefile will take it (mixed geometry types when saving).
Repairing the Isle of Wight's invalid polygons
The data used on this page
Every polygon in the OpenStreetMap extract for the Isle of Wight, from Geofabrik, read through GDAL's OSM driver and projected to British National Grid: 86,892 buildings, land use, natural areas and others, with the OSM id and a kind column. Twenty of them are invalid as mapped.
import os
import geopandas as gpd
import requests
os.makedirs("data", exist_ok=True)
os.makedirs("output", exist_ok=True)
if not os.path.exists("data/iow_polygons.gpkg"):
pbf = "isle-of-wight-260101.osm.pbf"
if not os.path.exists(pbf):
with requests.get("https://download.geofabrik.de/europe/united-kingdom/england/"
"isle-of-wight-260101.osm.pbf", stream=True, timeout=300) as reply:
with open(pbf, "wb") as out:
for chunk in reply.iter_content(1 << 20):
out.write(chunk)
osm = gpd.read_file(pbf, layer="multipolygons",
columns=["osm_id", "osm_way_id", "name", "building", "landuse", "natural"])
osm["kind"] = "other"
osm.loc[osm["natural"].notna(), "kind"] = "natural"
osm.loc[osm["landuse"].notna(), "kind"] = "landuse"
osm.loc[osm["building"].notna(), "kind"] = "building"
osm.to_crs(27700)[["osm_id", "osm_way_id", "name", "kind", "geometry"]].to_file("data/iow_polygons.gpkg")
layer = gpd.read_file("data/iow_polygons.gpkg")
print(len(layer), "polygons;", layer["kind"].value_counts().to_dict(), "|", (~layer.is_valid).sum(), "invalid")
86080 polygons; {'building': 71005, 'natural': 5319, 'landuse': 4982, 'other': 4774} | 19 invalid
Example 1: Detect invalid geometries in a shapefile
import geopandas as gpd
gdf = gpd.read_file("data/iow_polygons.gpkg")
gdf["is_valid"] = gdf.geometry.is_valid
invalid_gdf = gdf[~gdf["is_valid"]]
print("Invalid features:", len(invalid_gdf))
print(invalid_gdf.groupby("kind").size().to_dict())
Invalid features: 19
{'landuse': 10, 'natural': 2, 'other': 7}
None of the 19 are buildings: all are OpenStreetMap multipolygon relations β woods, fields and other areas assembled from several ways, which is where self-intersections creep in.
Example 2: Print validation reasons for bad features
from shapely.validation import explain_validity
invalid_gdf = invalid_gdf.copy()
invalid_gdf["reason"] = invalid_gdf.geometry.apply(explain_validity)
for idx, row in invalid_gdf[["reason"]].head(5).iterrows():
print(idx, row["reason"])
1 Self-intersection[458382.951875285 92054.0699323847]
52 Self-intersection[458626.186985012 82399.7905039306]
156 Self-intersection[454466.763903775 85739.6641310109]
206 Self-intersection[443751.136953349 88697.8067740456]
337 Self-intersection[453792.578786255 90676.1874381474]
Example 3: Repair geometries with make_valid()
from shapely import make_valid
gdf_fixed = gdf.copy()
mask = ~gdf_fixed.geometry.is_valid
gdf_fixed.loc[mask, "geometry"] = gdf_fixed.loc[mask, "geometry"].apply(make_valid)
print("Invalid after repair:", (~gdf_fixed.geometry.is_valid).sum())
print(gdf_fixed.geometry.geom_type.value_counts())
Invalid after repair: 0
MultiPolygon 86061
GeometryCollection 19
Name: count, dtype: int64
Example 4: Keep only polygon results after repair
polygon_types = ["Polygon", "MultiPolygon"]
gdf_polygons = gdf_fixed[gdf_fixed.geometry.geom_type.isin(polygon_types)].copy()
print(gdf_polygons.geometry.geom_type.value_counts())
print(len(gdf_fixed) - len(gdf_polygons), "repaired features lost β use polygon_parts() above instead")
MultiPolygon 86061
Name: count, dtype: int64
19 repaired features lost β use polygon_parts() above instead
Example 5: Drop empty geometries and save cleaned output
cleaned = gdf_fixed.copy()
cleaned = cleaned[cleaned.geometry.notna()]
cleaned = cleaned[~cleaned.geometry.is_empty]
cleaned = cleaned[cleaned.geometry.is_valid]
cleaned.to_file("output/iow_cleaned.gpkg", driver="GPKG")
print(len(cleaned), "features saved")
86080 features saved
Example 6: do invalid geometries still break operations?
import time
landuse = gdf[gdf["kind"] == "landuse"]
area = gpd.GeoDataFrame(geometry=[shapely.box(440000, 80000, 470000, 95000)], crs=gdf.crs)
for label, layer in (("as read", landuse), ("repaired", landuse.assign(geometry=landuse.make_valid(method="structure")))):
start = time.perf_counter()
dissolved = layer.dissolve(by="kind")
overlaid = gpd.overlay(layer, area, how="intersection")
clipped = gpd.clip(layer, area)
print(f"{label:9} dissolve {dissolved.area.sum() / 1e4:,.1f} ha, overlay {len(overlaid):,} rows, "
f"clip {len(clipped):,} rows β {time.perf_counter() - start:.1f} s, no error")
as read dissolve 29,330.7 ha, overlay 3,850 rows, clip 3,850 rows β 0.6 s, no error
repaired dissolve 29,330.7 ha, overlay 3,850 rows, clip 3,850 rows β 0.6 s, no error
None of the three raised. GEOS's overlay engine has been robust to many invalid inputs since version 3.9, so a missing TopologyException is not evidence that the data is valid β only that the operation found a way through. Check is_valid yourself.
Why a repair is a choice, not a fix
An invalid polygon's interior is ambiguous β a self-intersecting ring can be read as one shape or as two β so every repair has to pick a reading, and two correct repairs can disagree, as linework and structure did above.
The same ambiguity is why the operations no longer fail: GEOS since 3.9 picks a reading internally rather than raising, so dissolve, overlay and clip all completed (Example 6). That makes the check your job β is_valid before, and geom_type after β and it makes the choice of repair something to write down rather than leave to a default. What validity means formally is in geometry validity explained.
What a repair can change
- The geometry type. All 19 linework repairs came back as
GeometryCollection; filter on type and you delete them, so extract the polygonal parts instead. - The area. Linework mode reads a self-intersection as extra polygon; compare areas before and after on anything you will sum.
- Valid is not the same as right. A repair fixes structure, not a wrongly drawn boundary; the 19 here are OpenStreetMap relations whose source ways should be fixed upstream.
Internal links
- How to Read a Shapefile in Python with GeoPandas
- How to Export GeoJSON in Python with GeoPandas
- How to Reproject Spatial Data in Python (GeoPandas)
FAQ
What is the best way to fix invalid geometries in GeoPandas?
Use make_valid() from Shapely when available. It is the preferred method for most cases. Use buffer(0) only as a fallback for simple polygon issues.
Can I use buffer(0) to repair all invalid geometries?
No. It sometimes works for simple self-intersections, but it can change geometry shape and does not fix every invalid case reliably.
What should I do if repaired geometries become empty or still invalid?
Filter out null, empty, or still-invalid rows and inspect those features manually if they matter to the project. Save the cleaned output in a flexible format such as GeoPackage.
Can I fix every invalid geometry at once instead of masking the invalid rows?
Yes. Recent GeoPandas exposes gdf.geometry.make_valid() as a vectorized method, so gdf["geometry"] = gdf.geometry.make_valid() repairs the whole column in one call, and leaves valid geometries unchanged. Masking is worth it when invalid rows are rare: of the 86,080 Isle of Wight polygons, 19 need the call.
Does reprojecting with to_crs() fix invalid geometries?
No. to_crs() only transforms coordinates; a self-intersection or bad ring stays invalid after reprojection. Repair with make_valid() first, then reproject if needed.
How do I find which rows became GeometryCollection after make_valid()?
Filter on the geometry type: gdf[gdf.geom_type == "GeometryCollection"]. You can then extract just the polygon parts from each collection, or drop those rows, depending on what your downstream step expects.