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
shapely2.x, which exposesmake_validdirectly, andgeopandas0.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
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
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.
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.
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.
GeometryCollectionoutput.make_validon a polygon with dangling edges returns a collection containing lines. Filter to areal parts or every later.areacall 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.
Related Topics
- Boundary Topology Validation — the parent topic: the ordered checks this repair unblocks
- Validating Catchment Boundary Topology with GeoPandas and Shapely — the mass-balance check that catches a repair which moved area
- Detecting and Removing Sliver Polygons in Catchment Boundaries — the defect that survives validity repair and needs its own treatment
- Watershed Delineation & Catchment Synchronization — the section, and the four defect classes this sits inside