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.

Four ways a polygon ring goes wrong Four side-by-side panels. The first draws a bow-tie whose two edges cross at a marked point, and reports Self-intersection with a coordinate. The second draws three sides of a rectangle with the closing edge missing, and notes that this raises an exception while parsing rather than failing is_valid. The third draws a shell rectangle with a separate hole rectangle sitting outside it, reporting Hole lies outside shell. The fourth draws a rectangle with two coincident vertices on its lower edge, and reports Valid Geometry, because duplicate points do not break validity. Four ways a polygon ring goes wrong Only three of the four are visible to is_valid — and one of those three never gets that far. self-intersection is_valid_reason() Self-intersection[25 5] make_valid keeps both lobes area 0.00 → 50.00 buffer(0) gives 25.00 unclosed ring shapely refuses to build it GEOSException on parse never reaches is_valid an interchange-format bug, not a Python one hole outside shell shell hole is_valid_reason() Hole lies outside shell[52 2] no repair is neutral here linework invents area; re-digitise the ring instead duplicate vertices two points, one place is_valid_reason() Valid Geometry the check says nothing remove_repeated_points() Three failure classes, three different responses — a single blanket repair call handles at most one of them well.

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.

bash
pip install "geopandas>=1.0.1" "shapely>=2.1.0" "pyogrio>=0.9.0" \
            "pyproj>=3.6.1" "numpy>=1.26" "pandas>=2.2"
python
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.

python
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.

python
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}")
text
    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.

python
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))
text
 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.

python
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}"))
text
 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.

python
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}"))
text
 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.

make_valid against buffer(0) on the same bow-tie Three panels across the width. The left panel draws the input bow-tie polygon with its two edges crossing at a marked point, and an area bar reading zero. The middle panel shows make_valid returning two filled triangular lobes as a MultiPolygon with a full area bar reading fifty. The right panel shows buffer(0) returning only the right-hand lobe as a single Polygon, the left lobe drawn as a faded dashed outline, with a half-length area bar reading twenty-five. The same bow-tie, two repairs, two different parcels Bars show recovered area against the 50 m² the polygon actually covers. input · invalid Polygon, 1 part, is_valid False area = 0.00 m² make_valid() MultiPolygon, 2 parts, is_valid True area = 50.00 m² buffer(0) dropped Polygon, 1 part, is_valid True area = 25.00 m² Both repairs return a geometry that passes is_valid. Only one of them still describes the parcel. Comparing area before and after is the only cheap test that separates them.

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.

python
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}")
text
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:

python
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)
text
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.

python
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
text
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.

The four gates every geometry passes at ingest Four gate boxes run left to right, joined by arrows. Gate one tests for missing geometry with isna, gate two for empty geometry with is_empty, gate three for invalidity with is_valid, and gate four for the wrong geometry type with geom_type. Beneath each gate sits the remedy: quarantine, drop before building weights, repair with make_valid using the structure method, and explode then filter to polygonal parts. A single assertion line across the bottom combines the checks. The four gates every geometry passes at ingest Four different states, four different remedies — is_valid alone answers only the third. 1 · missing? gdf.geometry.isna() is_valid reports False 2 · empty? gdf.geometry.is_empty is_valid reports True 3 · invalid? gdf.geometry.is_valid is_valid_reason() names it 4 · wrong type? gdf.geom_type.unique() collection or stray line quarantine the row a supply problem, not a topology problem drop before weights empty geometry becomes an island in contiguity make_valid, structure then compare the area before and after explode and filter keep polygonal parts, dissolve back by id assert (g.is_valid & ~g.is_empty & g.notna()).all() one line in the reader, raised at load rather than in an overlay eight steps later Every gate is cheap; the cost of skipping one is a traceback that blames an innocent function.

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

← Back to GeoPandas Data Preparation