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

A self-intersecting bowtie polygon is invalid; make_valid splits it into two valid polygons. Invalid β€” self-intersection Valid after make_valid() spatialworkflow.io
A self-intersecting bowtie is invalid; make_valid() splits it into valid polygons.

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

Invalid geometry repairCheck.is_validexplain_validity()make_valid()Inspectgeom_typeFilteremptiesSave toGPKGspatialworkflow.io
The repair pipeline for invalid geometries.

Find, explain, repair and verify

What explain_validity reports and what each message means. reading explain_validity()messagewhat happenedusual causeSelf-intersectionthe ring crosses itselfbad digitisingRing Self-intersectiona bow-tie polygonedited verticesHole lies outside shellinner ring escapedbad exportNested holesholes inside holesoverlay artefactToo few pointsunder 4 verticestruncated dataThe message also carries the coordinate of the problem β€” plot it before repairingmake_valid() repairs all of these, but it may split one polygon into several spatialworkflow.io
Knowing which of these you have tells you whether a repair is safe or lossy.

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.

Three ways of repairing the 19 invalid Isle of Wight OpenStreetMap polygons and what each returned. three repairs of the same 19 polygonsreturnsarea changeinto a shapefilemake_valid()!GeometryCollection Γ—19+117 mΒ²βœ•refusedmake_valid(structure)βœ“Polygon / MultiPolygon0 mΒ²βœ“acceptedbuffer(0)βœ“Polygon / MultiPolygon0 mΒ²βœ“acceptedDefault linework mode keeps stray line segments; structure mode rebuilds polygons from rings spatialworkflow.io
Here buffer(0) agreed with structure mode; it does not always.

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
The repair sequence, ending in a check that the geometry type survived. valid is not the same as unchanged1.is_validwhich rows are broken2explain_validity()why, and at which coordinate3make_valid()buffer(0) only as a fallback4check geom_type againand filter before exportmake_valid can return a MultiPolygon or a GeometryCollection β€” valid, but not a polygon spatialworkflow.io
The fourth step is the one most repair snippets leave out.

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.

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.