This guide is part of Geometry Repair with Shapely & GeoPandas, which itself sits inside Automated Vector & Raster Cleaning Workflows.

Fixing Self-Intersecting Polygons in GeoPandas

Problem: A polygon whose boundary crosses itself triggers a TopologyException (or silent data corruption) in every downstream GEOS operation β€” joins, overlays, rasterization β€” because the OGC Simple Features model requires all rings to be non-self-intersecting planar loops.

How Self-Intersecting Polygons Break Spatial Pipelines

Self-intersecting polygons occur when a boundary ring crosses itself, producing bowtie shapes, overlapping lobes, or degenerate rings with zero-area segments. Format parsers rarely reject them, so they reach your pipeline silently.

  • gpd.overlay() and gpd.sjoin() raise TopologyException mid-batch, halting thousands of otherwise-valid features in the same chunk.
  • gdf.to_file() with GDAL vector drivers may silently drop or truncate invalid features when writing to Shapefile or GeoJSON, causing row-count mismatches that are hard to trace.
  • Rasterization via rasterio.features.rasterize() burns incorrect pixels for bowtie rings, corrupting raster masks used in land-cover classification or zonal statistics.
  • Spatial index lookups (STRtree) can return false negatives for invalid geometries, breaking point-in-polygon and nearest-neighbour queries at scale.

Because automated ETL pipelines process features in bulk, a single invalid polygon in a batch of 100,000 features can halt the entire run or β€” worse β€” produce subtly wrong aggregations without raising an exception.


Self-intersecting polygon repaired by make_valid() Left side shows a bowtie polygon where the boundary crosses itself at a single point. An arrow labeled make_valid() points right to a MultiPolygon containing two separate valid triangles. Self-intersecting (bowtie ring) ❌ is_valid = False make_valid() MultiPolygon (two valid parts) βœ“ is_valid = True

Version and Environment Compatibility

Environment Repair Method Behaviour & Caveats
Shapely 2.0+ / GEOS 3.10+ gdf.geometry.make_valid() Deterministic, topology-preserving. Splits self-intersections into valid MultiPolygon components without dropping vertices.
Shapely 1.8–1.9 / GEOS 3.9 gdf.geometry.buffer(0) Legacy workaround. Can drop collinear vertices, alter area by ≀0.1%, and occasionally invert ring orientation.
GeoPandas 0.13+ Vectorized .make_valid() Fully vectorized β€” no Python-level loop required. Always filter with is_valid first to skip already-valid geometries.
Python 3.9 / older pip Manual GEOS build needed Pre-built Shapely 2.x wheels require Python 3.9+. Pinned environments on 3.8 must compile GEOS from source or use shapely==1.8.*.

CRS note: Always validate and repair in a projected coordinate system (a local UTM zone or EPSG:3857). CRS normalization across mixed datasets should run before topology operations so angular WGS84 units don’t mask micro-intersections near the antimeridian or poles.

Production-Ready Repair Function

Install the required packages if not already present:

pip install geopandas>=0.14 shapely>=2.0 packaging

The function below integrates detection, version-aware repair, post-repair validation, and structured logging. It operates on a boolean mask so valid geometries are never touched.

import geopandas as gpd
import shapely
import logging
from packaging import version

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
logger = logging.getLogger(__name__)

SHAPELY_2 = version.parse(shapely.__version__) >= version.parse("2.0.0")


def repair_self_intersections(
    gdf: gpd.GeoDataFrame,
    repair_crs: str = "EPSG:3857",
    preserve_original_crs: bool = True,
) -> tuple[gpd.GeoDataFrame, int]:
    """
    Detect and repair self-intersecting polygons in a GeoDataFrame.

    Parameters
    ----------
    gdf : GeoDataFrame
        Input vector layer; must have a geometry column.
    repair_crs : str
        Projected CRS to use during GEOS topology operations.
        Default EPSG:3857 (Web Mercator) suits global datasets;
        prefer a local UTM zone for area-sensitive repairs.
    preserve_original_crs : bool
        If True, reproject back to the source CRS after repair.

    Returns
    -------
    tuple[GeoDataFrame, int]
        (repaired GeoDataFrame, count of geometries repaired)
    """
    if gdf.empty:
        logger.info("Empty GeoDataFrame β€” nothing to repair.")
        return gdf, 0

    original_crs = gdf.crs

    # Reproject to planar CRS so GEOS distance calculations are metric
    working = gdf.to_crs(repair_crs) if gdf.crs else gdf.copy()

    # Vectorized validity mask β€” never iterate row-by-row
    invalid_mask = ~working.geometry.is_valid
    invalid_count = int(invalid_mask.sum())

    if invalid_count == 0:
        logger.info("No self-intersecting polygons detected β€” returning unchanged.")
        result = working.to_crs(original_crs) if (preserve_original_crs and original_crs) else working
        return result, 0

    logger.info("Found %d invalid geometries; applying repair...", invalid_count)

    if SHAPELY_2:
        # make_valid() splits bowtie rings into MultiPolygon components,
        # preserving all vertices and conserving total area.
        working.loc[invalid_mask, "geometry"] = (
            working.loc[invalid_mask, "geometry"].make_valid()
        )
    else:
        # buffer(0) is the legacy fallback β€” accepts tiny area changes
        # and may collapse degenerate rings to LineString; filter those out.
        logger.warning(
            "Shapely < 2.0 detected β€” using buffer(0) fallback. "
            "Expect up to 0.1%% area variance and possible vertex loss."
        )
        working.loc[invalid_mask, "geometry"] = (
            working.loc[invalid_mask, "geometry"].buffer(0)
        )

    # Post-repair audit: check for geometries that are still invalid
    # or that make_valid() reduced to empty GeometryCollection.
    still_invalid = (~working.geometry.is_valid).sum()
    empty_geoms = working.geometry.is_empty.sum()

    if still_invalid > 0:
        logger.warning(
            "%d geometries remain invalid after repair β€” "
            "consider manual inspection or topology simplification.",
            still_invalid,
        )
    if empty_geoms > 0:
        logger.warning(
            "%d geometries are empty after repair (degenerate inputs). "
            "Route these to a dead-letter queue.",
            empty_geoms,
        )

    if preserve_original_crs and original_crs:
        working = working.to_crs(original_crs)

    return working, invalid_count

Key Implementation Notes

  • Vectorized boolean mask, not .apply(): ~working.geometry.is_valid returns a pandas Series; using .loc[invalid_mask, "geometry"] restricts make_valid() to only the rows that need repair β€” a critical performance difference on datasets with millions of features where the invalid fraction is small.
  • Separate repair CRS from source CRS: Projecting to EPSG:3857 (or a local UTM) before GEOS operations eliminates false negatives caused by angular WGS84 arithmetic, then the final to_crs(original_crs) restores pipeline compatibility without a permanent CRS change.
  • packaging.version for safe version branching: String comparison (shapely.__version__ >= "2.0.0") breaks on three-part versions like "1.8.10"; packaging.version.parse handles semantic versioning correctly.
  • Empty geometry detection after make_valid(): Degenerate inputs β€” polygons with zero area, all-collinear vertices, or precision-collapsed rings β€” can emerge from make_valid() as empty GeometryCollection objects that are technically valid but useless. These must be caught and routed to a dead-letter queue before downstream joins attempt to process them.
  • Tuple return with repair count: Returning (gdf, invalid_count) lets orchestration layers (Airflow tasks, Prefect flows) emit repair metrics to dashboards without requiring callers to re-scan the dataset.
  • No ring-orientation normalization included: make_valid() does not enforce consistent winding order. If your downstream rasterization or OGR export requires CCW exterior rings, call shapely.geometry.polygon.orient(geom, sign=1.0) on repaired polygons as a separate step.

Troubleshooting Self-Intersections: Common Causes and Fixes

Symptom Root Cause Fix
TopologyException: side location conflict during overlay() Bowtie or figure-eight ring passed to GEOS boolean operations Run repair_self_intersections() before any overlay call
Repair returns MultiPolygon where Polygon is expected make_valid() splits the intersection into components Explode with gdf.explode(index_parts=False) if downstream requires single-part geometries
buffer(0) produces LineString output Ring collapsed to zero area after vertex loss Drop non-polygon geometries: gdf[gdf.geometry.geom_type.isin(["Polygon","MultiPolygon"])]
Repair count matches expectation but joins still fail Coordinate precision differs between layers (snapping gap) Apply coordinate rounding and precision handling after geometry repair
is_valid passes but gpd.overlay() still raises Mixed CRS between left and right GDFs Ensure both GDFs share the same CRS; see converting mixed EPSG codes to a unified CRS

Integration: Where This Recipe Fits in the Cleaning Pipeline

This function belongs at the geometry validation gate β€” immediately after data ingestion and CRS normalization, but before spatial indexing, joins, or any overlay operation. Within the broader Geometry Repair with Shapely & GeoPandas workflow, self-intersection repair precedes sliver removal and gap closure because those steps rely on valid planar topology to measure ring areas accurately.

For datasets that mix point, line, and polygon features, run repair_self_intersections() only on the polygon subset (filter with gdf[gdf.geometry.geom_type.isin(["Polygon","MultiPolygon"])]). If your pipeline also handles duplicate features, wire the output directly into removing duplicate spatial points with tolerance thresholds to catch coincident centroids introduced when make_valid() splits a bowtie into two touching polygons.

For attribute-side issues that often accompany digitization errors β€” mismatched column names and inconsistent field encodings β€” see standardizing column names across multiple shapefiles after the geometry stage is clean.