How to Delineate a Watershed from a Pour Point in Python
Problem statement
You have a DEM and an outlet — a gauge, a bridge, a sampling point — and need the area that drains to it, as a polygon you can measure, map and overlay. The steps are standard: condition the DEM, route flow, snap the outlet to the channel, trace upstream, polygonise. What is not standard is how good the result is, and that depends mostly on the DEM and the outlet rather than on the code.
Measured for Esopus Creek above the USGS gauge at Coldbrook, New York — published drainage area 497.3 km² — against the USGS NLDI basin polygon:
- With the 3DEP 10 m DEM, the watershed was 492.44 km², 0.97% under the published area, with an intersection-over-union of 0.985 against the NLDI polygon.
- With the 30 m Copernicus GLO-30 surface model, it was 450.39 km², 9.4% short, IoU 0.897 and 51.3 km² of disagreement.
- Conditioning took 18.7 s at 10 m and 0.22 s at 30 m; flow routing, snapping, tracing and polygonising together took 0.77 s and 0.17 s.
- The 10 m polygon came out in 3 parts, and its geodesic area was 492.80 km² against 492.44 km² measured on the UTM grid.
Quick answer
import numpy as np
if not hasattr(np, "in1d"):
np.in1d = lambda a, b, **kw: np.isin(np.ravel(a), b, **kw)
import geopandas as gpd
from pysheds.grid import Grid
from shapely.geometry import shape
grid = Grid.from_raster("esopus_breached.tif") # a conditioned, projected DEM
fdir = grid.flowdir(grid.read_raster("esopus_breached.tif"))
acc = grid.accumulation(fdir)
x, y = grid.snap_to_mask(acc > 10000, (560427.2, 4651640.7)) # outlet moved onto a channel > 1 km2
catchment = grid.catchment(x=x, y=y, fdir=fdir, xytype="coordinate")
grid.clip_to(catchment)
polygons = [shape(g) for g, v in grid.polygonize(grid.view(catchment, dtype=np.uint8)) if v == 1]
watershed = gpd.GeoDataFrame(geometry=polygons, crs=grid.crs.srs).dissolve()
print(f"{watershed.area.iloc[0] / 1e6:.2f} km2")
492.02 km2
Condition the DEM first, snap the outlet to a channel of the expected size, and compare the area with a published figure.
Step-by-step solution
1. Choose and prepare the DEM
Use a bare-earth DEM in a projected CRS with metre cells, covering the whole catchment with margin. The 3DEP 1/3 arc-second DEM, reprojected to UTM zone 18N at 10 m, covered the basin in 2,406 × 3,470 cells; GLO-30 at 30 m in 802 × 1,157. Check that nodata is tagged: untagged nodata borders become cliffs that attract flow.
2. Condition it
Least-cost breaching with filling as a fallback — WhiteboxTools' breach_depressions_least_cost with a search of 1 km (100 cells at 10 m, 33 at 30 m) — took 18.68 s and 0.22 s. See breaching or filling a DEM for the options and their side effects.
3. Route flow
pysheds' flowdir and accumulation on the conditioned 10 m DEM took 0.42 s together. Accumulation is what the outlet snaps to and what the watershed's size can be read from before tracing it.
4. Snap the outlet
The gauge's coordinates lay beside the derived channel. The highest accumulation within 150 m was 160 m away diagonally on the 10 m grid and 163 m on the 30 m grid, on cells draining 492.44 km² and 450.39 km². For outlets near confluences, snap to the nearest channel of the expected size instead — see pour points explained.
5. Trace the catchment
grid.catchment walks flow directions upstream from the outlet and returns a boolean grid: 4,924,422 cells at 10 m, matching the accumulation at the outlet exactly. Snapping and tracing took 0.18 s.
6. Polygonise and dissolve
grid.polygonize on the clipped catchment returned the raster's outline as polygons; dissolved, the 10 m watershed had 3 parts — a main polygon and cells joined only at corners — with a total area of 492.44 km² on the UTM grid. Polygonising took 0.17 s. Remove slivers smaller than a few cells if you need a single polygon, and report that you did.
7. Measure area correctly
Area measured in UTM metres was 492.44 km²; the geodesic area of the same polygon on the WGS84 ellipsoid was 492.80 km². The difference, 0.07%, is the UTM scale factor at this location. For published figures use geodesic or equal-area measurements; see catchment areas on geographic DEMs.
8. Validate against a reference
The 10 m watershed was 0.97% under the published 497.28 km² and overlapped the NLDI polygon with an IoU of 0.9853, differing by 7.31 km². The 30 m watershed was 9.43% under, IoU 0.8972, differing by 51.26 km². The two DEM watersheds disagreed with each other by 47.35 km² (IoU 0.9044): the source, not the software, made the difference.
Code examples
Example 1 — delineate and polygonise, with timings
import os
import time
import rasterio
import whitebox
from pyproj import Geod, Transformer
def delineate(raw_dem, lon, lat, radius_m=150, workdir="work"):
os.makedirs(workdir, exist_ok=True)
timings = {}
start = time.perf_counter()
wbt = whitebox.WhiteboxTools()
wbt.set_verbose_mode(False)
with rasterio.open(raw_dem) as src:
cell = abs(src.res[0])
conditioned = os.path.abspath(os.path.join(workdir, os.path.basename(raw_dem).replace(".tif", "_cond.tif")))
wbt.breach_depressions_least_cost(os.path.abspath(raw_dem), conditioned, dist=int(1000 / cell), fill=True)
timings["condition"] = time.perf_counter() - start
start = time.perf_counter()
grid = Grid.from_raster(conditioned)
fdir = grid.flowdir(grid.read_raster(conditioned))
acc = np.asarray(grid.accumulation(fdir))
timings["route"] = time.perf_counter() - start
start = time.perf_counter()
x, y = Transformer.from_crs("EPSG:4269", grid.crs.srs, always_xy=True).transform(lon, lat)
col, row = ~grid.affine * (x, y)
k = int(np.ceil(radius_m / cell))
window = acc[int(row) - k:int(row) + k + 1, int(col) - k:int(col) + k + 1]
dr, dc = np.unravel_index(window.argmax(), window.shape)
ox, oy = grid.affine * (int(col) - k + dc + 0.5, int(row) - k + dr + 0.5)
catchment = grid.catchment(x=ox, y=oy, fdir=fdir, xytype="coordinate", snap="center")
timings["snap + trace"] = time.perf_counter() - start
start = time.perf_counter()
grid.clip_to(catchment)
parts = [shape(g) for g, v in grid.polygonize(grid.view(catchment, dtype=np.uint8)) if v == 1]
watershed = gpd.GeoDataFrame(geometry=parts, crs=grid.crs.srs).dissolve()
timings["polygonise"] = time.perf_counter() - start
geodesic = abs(Geod(ellps="WGS84").geometry_area_perimeter(watershed.to_crs(4326).geometry.iloc[0])[0]) / 1e6
print(f"{os.path.basename(raw_dem)}: {len(parts)} part(s), {watershed.area.iloc[0] / 1e6:.2f} km2 projected, "
f"{geodesic:.2f} km2 geodesic; outlet moved {np.hypot(ox - x, oy - y):.0f} m")
print(" " + ", ".join(f"{step} {seconds:.2f} s" for step, seconds in timings.items()))
return watershed
w10 = delineate("esopus_3dep13_utm_basin.tif", -74.2701944, 42.0144722)
w30 = delineate("esopus_glo30_utm_basin.tif", -74.2701944, 42.0144722)
esopus_3dep13_utm_basin.tif: 3 part(s), 492.44 km2 projected, 492.80 km2 geodesic; outlet moved 160 m
condition 18.06 s, route 0.44 s, snap + trace 0.18 s, polygonise 0.18 s
esopus_glo30_utm_basin.tif: 1 part(s), 450.39 km2 projected, 450.72 km2 geodesic; outlet moved 163 m
condition 0.23 s, route 0.05 s, snap + trace 0.04 s, polygonise 0.06 s
Example 2 — compare with a reference polygon and a published area
def compare(watershed, reference, published_km2, label):
a, b = watershed.geometry.iloc[0], reference.to_crs(watershed.crs).geometry.iloc[0]
iou = a.intersection(b).area / a.union(b).area
area = watershed.area.iloc[0] / 1e6
print(f"{label}: {area:.2f} km2 ({100 * (area / published_km2 - 1):+.2f}% vs published), "
f"IoU {iou:.4f}, symmetric difference {a.symmetric_difference(b).area / 1e6:.2f} km2")
nldi = gpd.read_file("nldi_basin_01362500.geojson")
compare(w10, nldi, 497.28, "3DEP 10 m")
compare(w30, nldi, 497.28, "GLO-30")
compare(w30, w10, w10.area.iloc[0] / 1e6, "GLO-30 against 3DEP")
3DEP 10 m: 492.44 km2 (-0.97% vs published), IoU 0.9853, symmetric difference 7.31 km2
GLO-30: 450.39 km2 (-9.43% vs published), IoU 0.8972, symmetric difference 51.26 km2
GLO-30 against 3DEP: 450.39 km2 (-8.54% vs published), IoU 0.9044, symmetric difference 47.35 km2
In the last line the "published" figure is the 3DEP watershed itself.
Example 3 — save the watershed with its provenance
w10.assign(source="USGS 3DEP 1/3 arc-second, UTM 18N 10 m", conditioning="least-cost breach 1 km + fill",
outlet="USGS 01362500, max accumulation within 150 m", area_km2=w10.area / 1e6).to_file("esopus_watershed.gpkg")
print(gpd.read_file("esopus_watershed.gpkg").iloc[0][["source", "conditioning", "outlet", "area_km2"]])
source USGS 3DEP 1/3 arc-second, UTM 18N 10 m
conditioning least-cost breach 1 km + fill
outlet USGS 01362500, max accumulation within 150 m
area_km2 492.4422
Name: 0, dtype: object
Keeping the DEM source, conditioning and snapping rule with the polygon makes the area reproducible and explains why it differs from someone else's.
Explanation
Why the DEM matters more than the tool
Every step after conditioning is deterministic: the same flow directions give the same catchment in any software. What changes the answer is where the DEM puts the drainage divides and channels. A 30 m surface model smooths narrow ridges, includes tree canopy and blurs valley floors; along a divide 100 km long, a shift of one or two cells moves square kilometres of area from one side to the other.
Why the polygon has several parts
A D8 catchment is a set of cells connected by flow, not by shared edges. Two cells can belong to the catchment while touching only at a corner, and polygonising treats corner-touching cells as separate polygons. Small extra parts are an artefact of the raster representation, not separate watersheds.
Why projected and geodesic areas differ
UTM preserves shapes locally but scales distances by up to 0.1% within a zone. Area measured on the grid inherits that scale squared. Geodesic area on the ellipsoid does not, which is why it was 0.36 km² larger here. Both are far more accurate than the DEM itself; the point is to state which one you report.
Why a good watershed still misses the published area
The published drainage area is itself an estimate from maps and older DEMs, and the NLDI polygon from NHDPlus data. The 10 m watershed matched both to about 1%, which is the level at which DEM resolution, conditioning and reference data all contribute. Differences above a few per cent point at a specific problem worth finding.
Edge cases or notes
- Catchments larger than the DEM are truncated; check that no watershed cell touches the edge.
- Reservoirs and lakes make flow paths across them arbitrary; mask or burn if the outlet is below one.
- Transboundary basins may need DEMs from several sources; mosaic and reproject them to one grid first.
- Very large basins at 10 m can exhaust memory in pysheds; process at coarser resolution or in WhiteboxTools, which works from files.
- Several outlets are better handled together; see splitting a catchment into sub-basins.
- Geographic DEMs need per-row cell areas; see catchment areas on a latitude–longitude DEM.
- Simplifying the polygon for display changes its area; measure before simplifying.
Internal links
- Pour points explained: why watershed delineation needs a snapped outlet — snapping the outlet
- How to condition a DEM for hydrology: breaching or filling — preparing the DEM
- Fixing a watershed that comes out as a few pixels — when delineation fails
- How to split a catchment into sub-basins at many outlets — nested watersheds
- How to summarise catchment attributes: area, slope and land cover — what to do with the polygon
- How DEM resolution and source change a drainage network — why the 30 m result differed
- DEM hydrology explained: from elevation to where water goes — the concepts behind each step
- How to convert a raster to a vector in Python — polygonising rasters in general
FAQ
How do I delineate a watershed in Python?
Condition the DEM, compute flow direction and accumulation, snap the outlet to the channel, call pysheds' catchment or WhiteboxTools' watershed, then polygonise the result. For Esopus Creek at 10 m it matched the published area within 1%.
Which DEM should I use for watershed delineation?
A bare-earth DEM at the finest practical resolution. The 3DEP 10 m DEM was 0.97% off the published area; the 30 m GLO-30 surface model 9.43%.
How long does watershed delineation take?
For a 500 km² basin at 10 m, about 19 s to condition the DEM and under 1 s for routing, snapping, tracing and polygonising.
Why is my watershed polygon made of several parts?
Cells joined only at corners polygonise as separate parts. The Esopus watershed had 3 parts; dissolve them, and drop tiny slivers if a single polygon is needed.
How should I measure the watershed area?
Geodesically or in an equal-area projection for reporting. The UTM area was 492.44 km² and the geodesic area 492.80 km².
How do I check a delineated watershed?
Compare its area with a published drainage area and its boundary with a reference polygon. IoU against the USGS NLDI basin was 0.985 at 10 m.