Validating and Repairing Invalid Polygon Geometry

This guide, part of Spatial Data Types & Formats in Production Epidemiology Pipelines, is about the repair itself: what each class of invalid polygon looks like, which repair method suits it, and how to confirm the repaired shape still describes the area the data belong to.

Problem Context & Constraints

Invalid polygons enter health data from ordinary processes. A service area traced by hand crosses its own boundary where two tracing passes met. A format conversion drops the closing vertex of a ring. A simplification step collapses a narrow peninsula into a bow-tie. A hole digitised as a separate polygon ends up outside its shell after a coordinate transformation.

Most spatial libraries will refuse to overlay an invalid polygon, which is the good case. The bad case is an operation that accepts it and returns something plausible and wrong: an area computed as the signed sum of two lobes that partially cancel, a point-in-polygon test that flips at a self-intersection, a union that silently drops a lobe.

The standard repair tools do not all behave the same way. make_valid restructures the geometry to a valid one that covers the same point set, which for a bow-tie means a multipolygon of two triangles — correct, but now a different geometry type. The older buffer(0) trick also produces a valid result but chooses which lobes to keep by winding order, and can delete half a bow-tie outright. Manual repair — re-digitising — is the only method that recovers the intended shape, and is only practical for a few features.

The constraint that ties this together is that a repair must be verified, not assumed: compare the repaired area to the original, and review any feature whose area changed by more than a small tolerance.

Four Classes of Invalid Polygon Four panels. A bow-tie self-intersection, typically introduced by simplification collapsing a narrow neck. An unclosed ring whose last vertex does not return to its first, typically from a lossy format conversion. A hole lying outside its shell, typically after separate transformation of rings. A ring with a repeated spike vertex producing a zero-width excursion, typically from snapping. Each panel names the introducing process and the repair method that suits it. Each class has a typical cause and a suitable repair Bow-tie from simplification make_valid, then review Unclosed ring from format conversion close the ring Hole outside shell from ring-by-ring transforms rebuild from source Spike from snapping remove repeated points diagnose the class before choosing the repair; one tool does not fit all four

Prerequisites

  • python 3.11
  • shapely 2.0.4, geopandas 0.14.4, pandas 2.2.2
  • The layer in a projected metric coordinate reference system, so area comparisons are meaningful
  • A tolerance for acceptable area change, declared in advance
  • Access to the original source for features whose automatic repair fails review

Step-by-Step Solution

Classify each invalid feature by its validity reason, apply the repair suited to that class, keep only the geometry type the layer expects, and compare areas before and after.

# Classify, repair and verify invalid polygons.
# Pinned: shapely==2.0.4, geopandas==0.14.4, pandas==2.2.2
import logging
import re

import pandas as pd
import geopandas as gpd
import shapely
from shapely.validation import explain_validity, make_valid

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("geometry.repair")

AREA_TOLERANCE = 0.005     # repairs changing area by more than 0.5% need review


def classify(gdf: gpd.GeoDataFrame, id_col: str = "geoid") -> pd.DataFrame:
    bad = gdf[~gdf.geometry.is_valid]
    reasons = bad.geometry.map(explain_validity)
    cls = reasons.str.extract(r"^([A-Za-z ]+)")[0].str.strip()
    out = pd.DataFrame({id_col: bad[id_col], "reason": reasons, "class": cls})
    log.info("invalid features by class: %s", out["class"].value_counts().to_dict())
    return out


def _areal_only(geom):
    """make_valid may return a collection with lines or points; keep areal parts."""
    parts = [g for g in getattr(geom, "geoms", [geom])
             if g.geom_type in ("Polygon", "MultiPolygon") and not g.is_empty]
    return shapely.union_all(parts) if parts else shapely.Polygon()


def repair(gdf: gpd.GeoDataFrame, id_col: str = "geoid") -> tuple[gpd.GeoDataFrame, pd.DataFrame]:
    g = gdf.copy()
    before = g.geometry.area
    mask = ~g.geometry.is_valid
    # Repeated vertices and spikes first: cheap and shape-preserving.
    g.loc[mask, "geometry"] = shapely.remove_repeated_points(g.loc[mask, "geometry"].values)
    still = ~g.geometry.is_valid
    g.loc[still, "geometry"] = g.loc[still, "geometry"].map(make_valid).map(_areal_only)
    after = g.geometry.area
    change = ((after - before).abs() / before.where(before > 0)).fillna(0.0)
    report = pd.DataFrame({id_col: g[id_col], "repaired": mask.values,
                           "area_before": before, "area_after": after,
                           "area_change_frac": change})
    report = report[report["repaired"]]
    review = report[report["area_change_frac"] > AREA_TOLERANCE]
    log.info("repaired %d features; %d changed area by more than %.1f%% and need review",
             len(report), len(review), 100 * AREA_TOLERANCE)
    still_bad = int((~g.geometry.is_valid).sum())
    if still_bad:
        log.error("%d features remain invalid after repair", still_bad)
    return g, report

Removing repeated points before calling make_valid is a small ordering choice with a large effect. Many invalidities are spikes and duplicate vertices introduced by snapping, and removing them fixes the geometry without restructuring it. Calling make_valid first would also produce a valid result, but sometimes by splitting the polygon at the spike, which changes the topology for no reason.

Keeping only the areal parts after make_valid matters because the function is allowed to return a geometry collection containing lines or points where a polygon collapsed to zero width. A layer of tract polygons should contain polygons; a stray line segment in the geometry column will break an overlay later in a confusing way.

Two Repairs, Two Different Answers The same bow-tie polygon repaired two ways. The make_valid repair returns a multipolygon of both triangles, preserving the full point set the original covered, with total area equal to the sum of both lobes. The buffer-zero repair resolves the self-intersection by winding order and returns only one triangle, discarding the other lobe and roughly half the area. Both results are valid; only one of them still describes the whole area the data refer to, which is why the area comparison is essential. Both repairs are valid; only one keeps the whole area Original bow-tie invalid make_valid two triangles, full area buffer(0) one lobe dropped compare area before and after every repair, whichever tool you use

Validation & Edge Cases

A repaired geometry of a different type. A polygon that make_valid returns as a multipolygon changes the layer’s geometry type. Most downstream code handles multipolygons, but a format or database column typed as single polygons will reject them. Promote the whole column to multipolygon rather than forcing a single part.

Repairs that pass area review but move boundaries. A spike removal can leave area nearly unchanged while moving a shared boundary, opening a sliver against the neighbouring polygon. After repairing an areal layer that is meant to tile the study area, re-run the tessellation check from Writing Geometry Validity Assertions for a Pipeline.

Repair order that depends on input order. Running make_valid over features in whatever order a file delivers them is deterministic per feature, but a later dissolve or union across repaired features is not always order-independent at the vertex level. Sort on a stable identifier before repairing and before any operation that combines features, so a re-run produces byte-identical geometry.

Multipart features split by repair. A bow-tie tract repaired into two triangles is still one tract with one identifier and one population. Keep it as a single multipolygon row; exploding it into two rows duplicates the identifier and doubles the denominator in any join that follows.

Topology across the whole layer. Repairing polygons one at a time can leave shared edges no longer shared, because each feature’s repair is computed independently. For a tessellating layer, a coverage-aware cleaning step after repair restores the shared edges and should be the last geometry operation before the layer is published to the pipeline.

Features that remain invalid. Some pathological geometries survive every automatic repair. Those must be re-sourced or re-digitised; excluding them silently drops their cases from every aggregation.

Repairing the published file instead of the source. A repair applied downstream is lost when the source is refreshed. Report persistent invalidity to the producer and repair at ingestion with a recorded rule, so the same repair recurs deterministically on every refresh.

Invalid geometry that is intentional. Very occasionally a layer encodes something as a degenerate shape deliberately — a zero-area placeholder for an area with no mapped extent. Check the documentation before repairing, and exclude such features explicitly rather than turning them into something they were not meant to be.

INFO geometry.repair invalid features by class: {'Self-intersection': 11, 'Ring Self-intersection': 4, 'Too few points in geometry component': 2}
INFO geometry.repair repaired 17 features; 3 changed area by more than 0.5% and need review

The three features flagged for review are exactly the ones worth a human look: most often bow-ties where one lobe is a real part of the area and the other is a digitising error, and only the person reviewing can tell which. Their review outcome — accept, manually correct, or re-source — belongs in the repair record alongside the automatic results.

Route Repairs by How Much They Changed A workflow in which every repaired feature's area change is compared with a tolerance. Features whose area changed less than the tolerance are accepted automatically and logged. Features exceeding it are routed to human review, where each is accepted, manually corrected or sent back to the data producer for re-sourcing. Features that remain invalid after automatic repair go directly to re-sourcing. The outcome of every route is recorded so the same decisions recur on the next refresh. Automatic repair for the small changes, a person for the large ones repaired feature area change measured under tolerance accept and log over tolerance human review still invalid re-source accept · correct · re-source decision recorded

Compliance Notes

Record for each repaired feature its identifier, the invalidity reason, the repair method, the area before and after, and — for features over tolerance — the review outcome and reviewer role. Record the tolerance and the repair order as configuration.

Where repaired geometry is used to publish counts or rates, state in the product metadata that the source boundaries were repaired, how many features were affected, and by what method. A partner comparing your figures with their own will otherwise see small discrepancies at exactly the repaired features and have no way to explain them.