DEM Hydrology Explained: From Elevation to Where Water Goes
Problem statement
A digital elevation model says how high every cell is. It does not say where rain goes — that has to be derived, in a chain of steps where each one depends on the last: remove the depressions that would trap water, give every cell a flow direction, count how many cells drain through each one, pick an outlet on the resulting channel, and trace everything upstream of it. Skip or mishandle one step and the chain still produces a map, only of the wrong watershed.
Measured on the Esopus Creek catchment above the USGS gauge at Coldbrook, New York — published drainage area 497.3 km² — with the USGS 3DEP 10 m elevation model:
- On the raw DEM, no cell drained more than 11.95 km². 5,768 pits and 45,736 flat cells stopped flow long before it reached the gauge.
- Only 0.96% of the cells sat in depressions, and the median depression was 0.15 m deep. Conditioning changed less than 1% of the DEM and made the whole river connect.
- The conditioned DEM gave a 492.4 km² watershed, 1.0% under the published area, overlapping the USGS reference polygon with an intersection-over-union of 0.985.
- A gauge coordinate used as the outlet without snapping drained one cell. Moved 160 m onto the channel, it drained the whole basin.
Quick answer
The standard pipeline, one step per line, with WhiteboxTools:
import whitebox
wbt = whitebox.WhiteboxTools()
wbt.set_verbose_mode(False)
wbt.fill_depressions("dem.tif", "filled.tif", fix_flats=True) # 1. condition
wbt.d8_pointer("filled.tif", "d8.tif") # 2. flow direction
wbt.d8_flow_accumulation("d8.tif", "acc.tif", out_type="cells", pntr=True) # 3. flow accumulation
wbt.snap_pour_points("gauge.shp", "acc.tif", "outlet.shp", snap_dist=150) # 4. outlet on the channel
wbt.watershed("d8.tif", "outlet.shp", "watershed.tif") # 5. everything upstream
Use a projected DEM with metre cells, condition it before anything else, and check the watershed area against a published value before trusting it.
Step-by-step solution
1. Start from a projected, bare-earth DEM
Flow routing compares neighbouring elevations and measures distances and areas in cell units, so cells should be square and in metres. The 3DEP data were reprojected from geographic coordinates to UTM zone 18N at 10 m. A bare-earth model (a DTM) is the right source: surface models include trees and buildings that act as dams. Resolution matters too: the same workflow on the 30 m Copernicus GLO-30 DSM produced a 450.7 km² watershed, 9.4% short — see how DEM resolution changes a drainage network.
2. Remove depressions
Real landscapes have few closed hollows; elevation models have many, from measurement noise, bridges, culverts and interpolation. In the Esopus basin, 80,057 cells — 0.96% — were in depressions, most of them shallow: the median depth was 0.148 m, the deepest 10.13 m. Every depression is a local sink where flow stops. Filling raises each one to its spill point; breaching cuts a channel through its rim. Filling changed 80,209 cells; least-cost breaching changed 52,499. Both gave watersheds within 0.4 km² of each other — see breaching or filling a DEM.
3. Assign a flow direction to every cell
D8, the most common method, sends each cell's flow to the steepest of its eight neighbours. On the raw DEM, pysheds could not assign a direction to 45,736 flat cells and 5,768 pits. On the conditioned DEM those numbers fell to 3 flats and 194 pits, all at the edge of the data. D8 is simple and makes a clean single-thread network; multi-direction methods spread flow over hillslopes more realistically — see flow direction explained.
4. Accumulate flow
Flow accumulation counts, for every cell, how many cells drain through it. Multiplied by the cell area it is the upstream drainage area. On the raw DEM the maximum was 11.95 km², because every pit ended a flow path; on the conditioned DEM it was 513.5 km² at the edge of the data and 492.4 km² at the gauge. The values span six orders of magnitude: half of all cells received flow from 10 cells or fewer.
5. Put the outlet on the channel
A gauge's recorded coordinates are rarely exactly on the one-cell-wide channel the DEM produces. At Coldbrook, the gauge's cell had an accumulation of 1 — it drained only itself. The nearest cell with more than 1 km² of drainage was 40 m away. Snapping the outlet to the highest accumulation within 150 m moved it onto Esopus Creek and gave the full watershed — see pour points explained.
6. Trace the watershed
Walking the flow directions upstream from the outlet marks every cell that drains to it. The 3DEP watershed held 4,924,422 cells: 492.44 km², 0.97% under the published 497.28 km², with 7.31 km² of symmetric difference against the USGS NLDI basin polygon. Converted to a polygon, its geodesic area was 492.80 km².
7. Derive streams from accumulation
A stream network is the set of cells whose accumulation exceeds a threshold. A 1 km² threshold gave 257 segments and 332.9 km of streams; 0.1 km² gave 2,397 segments and 865.6 km. The threshold is a choice, not a property of the DEM — see extracting a stream network.
8. Validate against something independent
A published drainage area, a mapped basin boundary, or a reference stream network each catch different errors. A watershed that looks plausible can still be 74% too small, as one pysheds conditioning sequence produced here — see fixing a watershed that comes out as a few pixels.
Code examples
Example 1 — delineate a watershed from a DEM and a gauge coordinate
import os
import geopandas as gpd
import rasterio
import whitebox
from pyproj import Transformer
from shapely.geometry import Point
def delineate(dem, lon, lat, workdir="work", snap_m=150):
"""Fill, D8, accumulate, snap and trace; return the watershed area in km2."""
os.makedirs(workdir, exist_ok=True)
wbt = whitebox.WhiteboxTools()
wbt.set_verbose_mode(False)
wbt.set_working_dir(os.path.abspath(workdir))
with rasterio.open(dem) as src:
crs, cell = src.crs, src.res[0]
x, y = Transformer.from_crs("EPSG:4269", crs, always_xy=True).transform(lon, lat)
gpd.GeoDataFrame(geometry=[Point(x, y)], crs=crs).to_file(os.path.join(workdir, "gauge.shp"))
wbt.fill_depressions(os.path.abspath(dem), "filled.tif", fix_flats=True)
wbt.d8_pointer("filled.tif", "d8.tif")
wbt.d8_flow_accumulation("d8.tif", "acc.tif", out_type="cells", pntr=True)
wbt.snap_pour_points("gauge.shp", "acc.tif", "outlet.shp", snap_dist=snap_m)
wbt.watershed("d8.tif", "outlet.shp", "watershed.tif")
with rasterio.open(os.path.join(workdir, "watershed.tif")) as src:
cells = int((src.read(1) == 1).sum())
return cells * cell * cell / 1e6
area = delineate("esopus_3dep13_utm_basin.tif", -74.2701944, 42.0144722)
print(f"watershed {area:.2f} km2, {100 * (area / 497.28 - 1):+.2f}% against the published 497.28 km2")
watershed 492.42 km2, -0.98% against the published 497.28 km2
The gauge coordinates are USGS NAD83 values, so the transformer starts from EPSG:4269.
Example 2 — what conditioning changes
import numpy as np
if not hasattr(np, "in1d"): # pysheds 0.5 calls np.in1d, removed in NumPy 2.4
np.in1d = lambda a, b, **kw: np.isin(np.ravel(a), b, **kw)
from pysheds.grid import Grid
def routing_summary(path):
grid = Grid.from_raster(path)
dem = grid.read_raster(path)
fdir = grid.flowdir(dem)
acc = grid.accumulation(fdir)
cell_km2 = abs(grid.affine.a * grid.affine.e) / 1e6
print(f"{os.path.basename(path)}: flats {int((fdir == -1).sum()):,}, pits {int((fdir == -2).sum()):,}, "
f"largest drainage area {float(acc.max()) * cell_km2:.2f} km2")
routing_summary("esopus_3dep13_utm_basin.tif")
routing_summary("work/filled.tif")
esopus_3dep13_utm_basin.tif: flats 45,736, pits 5,768, largest drainage area 11.95 km2
filled.tif: flats 3, pits 194, largest drainage area 513.49 km2
pysheds marks cells with no downslope neighbour as −1 (flat) or −2 (pit) in its flow-direction grid, which makes the effect of conditioning easy to count.
Example 3 — how much of the DEM sits in depressions
def depression_report(dem, workdir="work"):
wbt = whitebox.WhiteboxTools()
wbt.set_verbose_mode(False)
wbt.set_working_dir(os.path.abspath(workdir))
wbt.depth_in_sink(os.path.abspath(dem), "sink_depth.tif", zero_background=True)
with rasterio.open(dem) as src:
valid = src.read(1) != src.nodata
with rasterio.open(os.path.join(workdir, "sink_depth.tif")) as src:
depth = src.read(1)
sinks = valid & (depth > 0)
print(f"cells in depressions {sinks.sum():,} ({sinks.sum() / valid.sum():.2%}); "
f"median depth {np.median(depth[sinks]):.3f} m, deepest {depth[valid].max():.2f} m")
depression_report("esopus_3dep13_utm_basin.tif")
cells in depressions 80,057 (0.96%); median depth 0.148 m, deepest 10.13 m
Explanation
Why raw elevation cannot route water
Flow routing needs every cell to have a lower neighbour until the flow reaches the edge of the data or a real sink. A DEM built from lidar or radar has millions of tiny irregularities, and any cell lower than all eight neighbours ends every flow path that reaches it. With 5,768 such pits in the basin, the longest connected flow path drained 11.95 km² — a stream the size of a small tributary.
Why a small change has a large effect
Depressions are small and shallow but they sit exactly where water collects: in valley bottoms, behind road embankments and bridges, along the channel. Raising 1% of cells by a median of 15 cm reconnected the valley floor, and because accumulation is cumulative, reconnecting a single cell on the main stem adds everything upstream of it.
Why the outlet must be snapped
A derived channel is one cell wide, and the gauge is a point measured independently of the DEM. Unless it happens to fall in exactly the right 10 m cell, its accumulation belongs to a hillslope beside the river. Snapping to high accumulation nearby corrects that, at the risk of jumping to a larger neighbouring river if the search radius is too generous.
Why the answer still differs from the published area
The published drainage area comes from a different method and source. The DEM channel may leave the true river at braided reaches, reservoirs or culverts, and a 10 m grid cannot represent the drainage divide more precisely than a cell. A 1% difference is normal; 10% or more points at the DEM source, the conditioning or the outlet.
Edge cases or notes
- pysheds 0.5 and NumPy 2.4+ fail with
AttributeError: module 'numpy' has no attribute 'in1d'; the one-line shim in Example 2 restores it. - Geographic DEMs give wrong areas unless each cell's area is computed from its latitude; see catchment areas on a latitude–longitude DEM.
- Reservoirs and lakes are large flat areas; flow crosses them along arbitrary paths after flat resolution.
- Karst and closed basins have real depressions that should not be filled.
- Roads and bridges create artificial dams in the DEM; see streams that stop at roads.
- Edges of the DEM cut off upstream area if the data do not cover the whole catchment.
- Surface models (DSMs) include vegetation and buildings; prefer a bare-earth DTM.
Internal links
- Flow direction explained: D8, D-infinity and MFD compared — the routing step in detail
- How to condition a DEM for hydrology: breaching or filling — removing depressions
- How to calculate flow accumulation from a DEM in Python — the accumulation step
- Pour points explained: why watershed delineation needs a snapped outlet — choosing the outlet
- How to delineate a watershed from a pour point in Python — the full delineation
- How to extract a stream network from a DEM — thresholds and channels
- Digital elevation models explained: DEM, DSM and DTM — choosing the source
- How to fill sinks and derive flow direction from a DEM — a shorter introduction
FAQ
What is DEM hydrology?
Deriving where water flows from an elevation model: conditioning the DEM, computing flow directions and accumulation, and using them to delineate watersheds and stream networks.
Why do I need to fill or breach a DEM before flow routing?
Depressions stop flow. On the raw Esopus DEM no cell drained more than 11.95 km²; after conditioning under 1% of cells, the watershed at the gauge was 492.4 km².
How accurate is a DEM-derived watershed?
With a 10 m bare-earth DEM and a snapped outlet, the Esopus watershed was 1.0% under the published area. The 30 m GLO-30 surface model gave a watershed 9.4% too small.
Why does my watershed contain only one cell?
The outlet is not on the derived channel. The Coldbrook gauge's own cell drained nothing else; snapping it 160 m to the highest accumulation nearby gave the whole basin.
Which Python libraries do DEM hydrology?
WhiteboxTools and pysheds are the common choices; both condition DEMs, compute D8 flow direction and accumulation and delineate watersheds. RichDEM and TauDEM are alternatives.
What cell size should the DEM have?
As fine as the landscape needs and in metres. A 10 m DEM resolved the Esopus basin to within 1%; at 30 m, narrow valleys and divides were lost.