Repairing Invalid Catchment Geometries with make_valid

Polygonised catchments arrive invalid often enough that most pipelines have a repair step, and most repair steps are buffer(0) — which fixes the validity flag and can quietly delete area. This guide covers what each defect actually is and which repair is correct for it, as part of the boundary topology validation topic within watershed delineation and catchment synchronization.

Prerequisites

  • shapely 2.x, which exposes make_valid directly, and geopandas 0.13+.
  • A catchment layer whose true total area is known from the raster, so a repair can be audited against it.

Core Technique: Three Defects, Three Repairs

Three Ways a Catchment Polygon Goes Invalid A bow-tie where the ring crosses itself becomes two polygons under make_valid but loses a lobe under buffer(0). A pinch point where the boundary touches itself across one cell splits into two polygons. A zero-area spike, where the ring goes out and back along the same line, is simply removed. bow-tie the ring crosses itself make_valid → 2 polygons buffer(0) → 1, area lost pinch point touches itself at one cell make_valid → 2 polygons area unchanged either way zero-area spike out and back along one line make_valid → spike removed area unchanged

The bow-tie is the case that decides the method. make_valid returns a MultiPolygon containing both lobes, preserving the point set. buffer(0) returns one polygon and discards the other, and reports nothing about having done so.

Annotated Code Example

python
import logging

import geopandas as gpd
import numpy as np
from shapely import make_valid
from shapely.geometry import MultiPolygon, Polygon

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


def repair_catchments(
    gdf: gpd.GeoDataFrame,
    area_tolerance_pct: float = 0.01,
    keep_multipart: bool = True,
) -> gpd.GeoDataFrame:
    """
    Repair invalid catchment geometries, auditing every change.

    Parameters
    ----------
    gdf                : Catchment polygons.
    area_tolerance_pct : Area change above which a repair is reported as an error.
    keep_multipart     : Keep both lobes of a bow-tie as a MultiPolygon. Setting
                         this False keeps only the largest part, which is
                         sometimes what a downstream consumer needs — but it
                         DELETES area, so it is logged loudly.
    """
    out = gdf.copy()
    original_area = out.geometry.area.to_numpy()
    invalid = ~out.geometry.is_valid
    n_invalid = int(invalid.sum())
    log.info("%d of %d geometries invalid (%.1f %%)",
             n_invalid, len(out), 100.0 * n_invalid / max(1, len(out)))

    if n_invalid == 0:
        return out

    # --- Report WHY each one is invalid before repairing. The reason decides
    # whether the repair is safe: a self-intersection may hide real area,
    # a ring-order problem never does. ---
    from shapely.validation import explain_validity
    reasons = {}
    for idx in out.index[invalid]:
        reason = explain_validity(out.geometry.loc[idx]).split("[")[0].strip()
        reasons[reason] = reasons.get(reason, 0) + 1
    for reason, count in sorted(reasons.items(), key=lambda kv: -kv[1]):
        log.info("  %4d × %s", count, reason)

    repaired = out.geometry.apply(lambda g: make_valid(g) if g and not g.is_valid else g)

    # --- make_valid can return a GeometryCollection when the input contained
    # dangling lines as well as area. Keep only the areal parts; the lines
    # carry no catchment and would break every later area computation. ---
    def areal_only(g):
        if g is None or g.is_empty:
            return g
        if g.geom_type in ("Polygon", "MultiPolygon"):
            return g
        parts = [p for p in getattr(g, "geoms", []) if p.geom_type == "Polygon"]
        if not parts:
            log.warning("Repair produced no areal part for a geometry — dropped")
            return None
        return parts[0] if len(parts) == 1 else MultiPolygon(parts)

    repaired = repaired.apply(areal_only)

    if not keep_multipart:
        def largest_part(g):
            if g is not None and g.geom_type == "MultiPolygon":
                return max(g.geoms, key=lambda p: p.area)
            return g
        before = repaired.area.to_numpy()
        repaired = repaired.apply(largest_part)
        lost = float((before - repaired.area.to_numpy()).sum())
        if lost > 0:
            log.error("keep_multipart=False discarded %.1f m² of catchment area "
                      "across %d feature(s)", lost,
                      int((before - repaired.area.to_numpy() > 0).sum()))

    out = out.set_geometry(repaired)
    out = out[out.geometry.notna()]

    # --- The audit. A repair that changes area has edited the catchment, not
    # repaired it, and every downstream drainage area is now wrong. ---
    new_area = out.geometry.area.to_numpy()
    common = min(len(original_area), len(new_area))
    delta = new_area[:common] - original_area[:common]
    pct = 100.0 * delta / np.maximum(original_area[:common], 1e-9)
    worst = int(np.argmax(np.abs(pct))) if common else 0

    still_invalid = int((~out.geometry.is_valid).sum())
    log.info("After repair: %d invalid, total area change %.3f m² (%.5f %%)",
             still_invalid, float(delta.sum()),
             100.0 * float(delta.sum()) / float(original_area.sum()))
    if common and abs(pct[worst]) > area_tolerance_pct:
        log.error("Largest single-feature area change is %+.3f %% — above the "
                  "%.3f %% tolerance; inspect that feature before proceeding",
                  pct[worst], area_tolerance_pct)
    if still_invalid:
        log.error("%d geometry(ies) remain invalid after make_valid — these are "
                  "usually empty or degenerate rings", still_invalid)
    return out


def compare_repair_methods(gdf: gpd.GeoDataFrame) -> gpd.GeoDataFrame:
    """
    Show, per feature, what buffer(0) would do that make_valid does not.

    Useful once, on a new data source, to decide whether the difference
    matters for that source.
    """
    invalid = gdf[~gdf.geometry.is_valid]
    rows = []
    for idx, geom in invalid.geometry.items():
        mv = make_valid(geom)
        b0 = geom.buffer(0)
        rows.append({
            "index": idx,
            "original_area": geom.area,
            "make_valid_area": mv.area,
            "buffer0_area": b0.area,
            "area_lost_by_buffer0": mv.area - b0.area,
            "make_valid_parts": len(getattr(mv, "geoms", [mv])),
        })
    df = gpd.pd.DataFrame(rows)
    if len(df):
        lost = df["area_lost_by_buffer0"]
        log.info("buffer(0) would lose area on %d of %d invalid features, "
                 "%.1f m² in total, worst %.1f m²",
                 int((lost > 1e-6).sum()), len(df), float(lost.sum()),
                 float(lost.max()))
    return df


# --- Example usage ---
# catchments = gpd.read_file("catchments.gpkg")
# print(compare_repair_methods(catchments))
# fixed = repair_catchments(catchments)

Parameter Reference

Choice Recommended Effect
make_valid vs buffer(0) make_valid Preserves the point set; buffer(0) can drop a lobe
keep_multipart True A bow-tie catchment is genuinely two pieces of ground
Areal-only filter On make_valid can return lines that break later area maths
area_tolerance_pct 0.01 Anything larger means the repair edited the catchment
Repair before or after reprojection Before Reprojection can create new invalidities from valid input

Worked Example: What the Audit Catches

A layer of 1 840 delineated catchments from a 1 m DEM:

Stage Invalid Total area Note
As polygonised 96 1 482.04 km² Mostly single-cell pinch points
After make_valid 0 1 482.04 km² Area unchanged to 5 decimal places
After buffer(0) instead 0 1 481.61 km² 0.43 km² gone, from 11 bow-ties
After keep_multipart=False 0 1 481.88 km² 0.16 km² gone, but reported

The third row is the one to avoid: 43 hectares vanished with no message. The fourth row lost less and said so, which at least makes it a decision.

Only One Repair Preserves the Area Total catchment area is 1482.04 square kilometres from the raster. make_valid returns 1482.04. Keeping only the largest part returns 1481.88. buffer(0) returns 1481.61, losing 0.43 square kilometres without any message. raster truth 1482.04 make_valid 1482.04 largest part only 1481.88 buffer(0) 1481.61 Bars are scaled to the deficit, not to the absolute area — the differences are fractions of a percent, and they attach to specific catchments rather than spreading evenly.

Validity is not a property that survives every operation. Re-checking after each transformation is cheap and stops an invalid geometry reaching a dissolve, where it produces wrong output rather than merely invalid output.

Where to Re-Check Validity Polygonising can produce pinch points. Reprojection can create self-intersections from valid input. Simplification can too. Writing can reorder rings. A dissolve over invalid input produces wrong results rather than invalid ones, so the check before it is the important one. polygonise pinch points reproject new crossings simplify moved vertices dissolve wrong, not invalid write check check check check The check before the dissolve is the one that matters most: an overlay over invalid input returns a result that is perfectly valid and quietly wrong.

Deciding what a multipart catchment means

make_valid returning a MultiPolygon is correct in the sense that it preserves the point set, but it leaves a question the geometry cannot answer: is this catchment genuinely two pieces of ground, or is it one piece that a raster artefact split?

The distinguishing evidence is in the flow grid, not in the polygon. A catchment that is genuinely multipart drains both parts to the same outlet, so tracing downstream from a cell in each part arrives at the same place. A catchment split by an artefact has one part that drains to the recorded outlet and another that drains somewhere else, which means the second part belongs to a neighbour.

Running that check on the handful of multipart features a repair produces is quick and it converts a geometry question into a hydrology one, which is where it belongs.

Repair as a recorded step, not a silent one

The repair is an edit to the data, and edits deserve the same treatment as any other processing step: a record of what changed, how much area moved and which features were touched. A layer that arrives with 96 invalid geometries and leaves with none has had 96 features rewritten, and a consumer comparing it against an earlier version will see those differences without knowing why.

Writing the repair report alongside the layer — feature identifiers, the validity reason, the area before and after — costs a few kilobytes and removes an entire category of later confusion. It also makes the next run’s report comparable: a source that consistently produces the same count of pinch points is behaving predictably, and a sudden change in that count is worth investigating upstream rather than repairing again.

Gotchas and Edge Cases

  • buffer(0) as the default repair. Fixes the flag, sometimes deletes area, never says which.
  • Repairing after reprojection. Reprojection can invalidate a valid geometry; repairing first and then reprojecting can leave you invalid again. Validate after every transformation.
  • GeometryCollection output. make_valid on a polygon with dangling edges returns a collection containing lines. Filter to areal parts or every later .area call is wrong.
  • Repair before dissolve. A dissolve over invalid inputs can produce results that are wrong rather than merely invalid. Repair first.
  • Assuming validity survives a write. Some drivers reorder rings on write. Re-validate after reading back.
  • Repairing without an area audit. The repair is the easy part; proving it changed nothing is the part that matters.
  • Precision-grid snapping treated as a repair. Snapping fixes a different problem — gaps between neighbours — and can itself create self-intersections on very thin features.