Height Above Nearest Drainage Explained: A Quick Flood Proxy
Problem statement
Flood maps come from hydraulic models: flows, cross-sections, roughness, calibration. Most of the world's streams have none. Height Above Nearest Drainage (HAND) offers a shortcut: from a DEM alone, give each cell its height above the stream it drains to, and treat low values as flood-prone. It is fast, repeatable and surprisingly informative. It is also easy to over-read: HAND has no discharge, no return period and no water surface, and its answer depends on two choices that have nothing to do with floods โ the stream network and the DEM.
Tested against the FEMA special flood hazard area (zones A and AE, 14.61 kmยฒ) in the Esopus Creek basin above Coldbrook, New York, with HAND from WhiteboxTools:
- The best match on the 10 m 3DEP DEM came from streams defined at 10 kmยฒ and a cut-off of 5 m: critical success index 0.578, 73.8% of the FEMA area captured, 14.8 kmยฒ predicted.
- Streams at 0.1 kmยฒ made nearly every small valley floor low: best CSI 0.335, with 32.6 kmยฒ predicted to capture 81% of the FEMA area.
- Streams at 50 kmยฒ measured tributary floodplains against the main river: the median HAND inside the FEMA area rose from 2.60 m to 10.07 m, and the best CSI fell to 0.399.
- On the 30 m GLO-30 surface model every threshold scored lower โ best 0.414, needing an 8 m cut-off and 21.1 kmยฒ of predicted area.
Quick answer
HAND is the vertical distance from a cell down to the stream cell its flow reaches:
HAND(cell) = elevation(cell) โ elevation(first stream cell downstream of it)
Low HAND means a cell sits little above the channel that drains it. It is a good screening layer for where flooding is plausible, a poor substitute for a flood map, and meaningless without the stream threshold and DEM it was computed from. Calibrate any cut-off against mapped floods before using it.
Step-by-step solution
1. Understand what HAND normalises
Raw elevation cannot separate floodplains from terraces across a basin: a valley floor at 800 m and one at 200 m look nothing alike. HAND subtracts the local drainage level, so both valley floors are near zero and the ridges between them are hundreds of metres. On the Esopus DEM the median HAND was 164.50 m; only 5.0% of cells were within 5 m of their stream.
2. See where the reference level comes from
Each cell is compared with the first stream cell on its D8 flow path. That makes HAND a property of the flow directions and the network together: change either and HAND changes, even with the same DEM.
3. Expect the stream threshold to dominate
The network is defined by an accumulation threshold. On the Esopus 3DEP DEM, against the FEMA area:
streams from median HAND in FEMA area 90th pct best cut-off CSI FEMA captured predicted
0.1 km2 1.18 m 4.06 m 3 m 0.335 81.0% 32.6 km2
1 km2 1.91 m 6.68 m 4 m 0.419 75.8% 22.9 km2
10 km2 2.60 m 9.53 m 5 m 0.578 73.8% 14.8 km2
50 km2 10.07 m 80.04 m 8 m 0.399 46.5% 9.2 km2
Low thresholds flag every hollow; high ones leave mapped floodplains on tributaries measured against a distant river (Example 1).
4. Match the threshold to the rivers the flood map covers
FEMA studies rivers and larger streams; a 10 kmยฒ network resembles what it mapped. For a different reference โ pluvial flooding, small-stream flash floods โ a different threshold will match better. There is no universal value.
5. Prefer a bare-earth DEM
A surface model includes canopy and buildings, which add height to cells on valley floors and distort channels. GLO-30 scored lower than 3DEP at every threshold: CSI 0.201, 0.290, 0.414 and 0.309 at 0.1, 1, 10 and 50 kmยฒ, and its median HAND inside the FEMA area at 10 kmยฒ was 2.86 m against 2.60 m (Example 2).
6. Calibrate the cut-off, don't borrow it
With 10 kmยฒ streams on 3DEP, CSI rose from 0.250 at 1 m to 0.578 at 5 m and fell to 0.496 at 10 m. Published cut-offs from other regions reflect their rivers, relief and DEMs; calibrate against local flood maps or observed flood extents.
7. Read HAND by flood zone, not only by threshold
Mapped flood zones differ in what they represent: detailed AE studies, approximate A zones and the 0.2%-annual-chance zone. Summarising HAND inside each (Example 3) shows whether a single cut-off can represent all of them. With 10 kmยฒ streams, AE zones had a median HAND of 2.89 m and the 0.2% zone 6.53 m, so the rarer flood sits visibly higher. Approximate A zones had a median of 1.14 m but a 90th percentile of 81.19 m: a tenth of their area stood more than 81 m above the nearest 10 kmยฒ stream, which no single cut-off can include without flooding hillsides. Outside every zone, only 0.6% of the basin was within 5 m of drainage.
8. Use HAND for what it is good at
Screening where detailed studies are missing, ranking assets by height above their stream, stratifying samples, and adding a terrain covariate to models. Not for insurance zones, design flood levels or anything needing a return period.
Code examples
Example 1 โ HAND at four stream thresholds against FEMA zones
import os
import geopandas as gpd
import numpy as np
import rasterio
import whitebox
from rasterio.features import rasterize
wbt = whitebox.WhiteboxTools()
wbt.set_verbose_mode(False)
wbt.set_working_dir(os.path.abspath("."))
basin = gpd.read_file("nldi_basin_01362500.geojson").to_crs(32618)
fema = gpd.read_file("fema_flood_hazard_esopus.gpkg").to_crs(32618)
fema["geometry"] = fema.geometry.make_valid()
def hand_rasters(dem, tag, thresholds_km2):
wbt.fill_depressions(dem, f"{tag}_filled.tif", fix_flats=True)
wbt.d8_pointer(f"{tag}_filled.tif", f"{tag}_d8.tif")
wbt.d8_flow_accumulation(f"{tag}_d8.tif", f"{tag}_acc.tif", out_type="cells", pntr=True)
with rasterio.open(dem) as src:
transform, shape = src.transform, src.shape
hands = {}
for km2 in thresholds_km2:
wbt.extract_streams(f"{tag}_acc.tif", f"{tag}_streams.tif", threshold=km2 * 1e6 / abs(transform.a * transform.e))
wbt.elevation_above_stream(f"{tag}_filled.tif", f"{tag}_streams.tif", f"{tag}_hand.tif")
with rasterio.open(f"{tag}_hand.tif") as src:
hands[km2] = src.read(1, masked=True)
return hands, transform, shape
def report(hands, transform, shape):
cell_km2 = abs(transform.a * transform.e) / 1e6
inside = rasterize(basin.geometry, out_shape=shape, transform=transform).astype(bool)
sfha = rasterize(fema.loc[fema.SFHA_TF == "T"].geometry, out_shape=shape, transform=transform).astype(bool) & inside
for km2, hand in hands.items():
in_zone = hand.data[sfha & ~hand.mask]
scores = []
for cutoff in (1, 2, 3, 4, 5, 6, 8, 10):
predicted = inside & ~hand.mask & (hand.data <= cutoff)
hits = (predicted & sfha).sum()
scores.append((hits / (predicted.sum() + (sfha & ~predicted).sum()), cutoff, hits / sfha.sum(), predicted.sum() * cell_km2))
csi, cutoff, captured, area = max(scores)
print(f"streams >= {km2:>4} km2: HAND in FEMA area median {np.median(in_zone):5.2f} m, 90th {np.percentile(in_zone, 90):5.2f} m; "
f"best CSI {csi:.3f} at <= {cutoff} m ({captured:.1%} captured, {area:.1f} km2)")
return inside
hand_3dep, transform_3dep, shape_3dep = hand_rasters("esopus_3dep13_utm.tif", "dep", (0.1, 1, 10, 50))
inside_3dep = report(hand_3dep, transform_3dep, shape_3dep)
streams >= 0.1 km2: HAND in FEMA area median 1.18 m, 90th 4.06 m; best CSI 0.335 at <= 3 m (81.0% captured, 32.6 km2)
streams >= 1 km2: HAND in FEMA area median 1.91 m, 90th 6.68 m; best CSI 0.419 at <= 4 m (75.8% captured, 22.9 km2)
streams >= 10 km2: HAND in FEMA area median 2.60 m, 90th 9.53 m; best CSI 0.578 at <= 5 m (73.8% captured, 14.8 km2)
streams >= 50 km2: HAND in FEMA area median 10.07 m, 90th 80.04 m; best CSI 0.399 at <= 8 m (46.5% captured, 9.2 km2)
Example 2 โ the same test on a 30 m surface model
hand_glo, transform_glo, shape_glo = hand_rasters("esopus_glo30_utm.tif", "glo", (0.1, 1, 10, 50))
report(hand_glo, transform_glo, shape_glo)
streams >= 0.1 km2: HAND in FEMA area median 1.18 m, 90th 11.53 m; best CSI 0.201 at <= 4 m (65.3% captured, 42.4 km2)
streams >= 1 km2: HAND in FEMA area median 2.00 m, 90th 12.71 m; best CSI 0.290 at <= 5 m (65.2% captured, 27.7 km2)
streams >= 10 km2: HAND in FEMA area median 2.86 m, 90th 16.81 m; best CSI 0.414 at <= 8 m (71.7% captured, 21.1 km2)
streams >= 50 km2: HAND in FEMA area median 16.62 m, 90th 82.24 m; best CSI 0.309 at <= 10 m (39.2% captured, 9.6 km2)
Example 3 โ HAND inside each kind of flood zone
hand = hand_3dep[10]
zones = {
"AE (detailed study)": fema.FLD_ZONE == "AE",
"A (approximate)": fema.FLD_ZONE == "A",
"X, 0.2% annual chance": fema.ZONE_SUBTY == "0.2 Percent Annual Chance Flood Hazard",
}
any_zone = np.zeros(shape_3dep, dtype=bool)
for name, rows in zones.items():
mask = rasterize(fema.loc[rows].geometry, out_shape=shape_3dep, transform=transform_3dep).astype(bool) & inside_3dep
any_zone |= mask
values = hand.data[mask & ~hand.mask]
print(f"{name:24} {mask.sum() / 1e4:6.2f} km2 HAND median {np.median(values):5.2f} m, 90th {np.percentile(values, 90):6.2f} m")
outside = hand.data[inside_3dep & ~any_zone & ~hand.mask]
print(f"{'outside all zones':24} {outside.size / 1e4:6.2f} km2 HAND median {np.median(outside):5.2f} m, share <= 5 m {np.mean(outside <= 5):.1%}")
AE (detailed study) 12.22 km2 HAND median 2.89 m, 90th 8.54 m
A (approximate) 2.39 km2 HAND median 1.14 m, 90th 81.19 m
X, 0.2% annual chance 3.35 km2 HAND median 6.53 m, 90th 12.22 m
outside all zones 477.16 km2 HAND median 216.31 m, share <= 5 m 0.6%
Explanation
Why HAND works as a flood proxy at all
Flood water rises from the channel. For a given flood, the cells that get wet are, to a first approximation, those less than some height above the water in the channel next to them. HAND measures exactly that height from the channel bed, so a single cut-off sketches the kind of zone a flood of a certain depth would make โ if every river rose by the same amount.
Why it is only a proxy
Rivers do not all rise by the same amount: a large river's flood stage is metres higher than a small stream's. Backwater from confluences, levees, bridges and channel capacity all shape real flooding and none are in HAND. And the D8 path to "nearest drainage" can cross a divide into a different stream where the DEM smooths a ridge, which misattributes the reference level.
Why the stream threshold matters so much
HAND is always relative to the network. At 0.1 kmยฒ the network reaches nearly every hollow, so hillside valley floors have near-zero HAND and flood-prone area is overpredicted. At 50 kmยฒ tributaries are not streams, so their floodplains are measured against the main river kilometres away, and their HAND is tens of metres โ the 90th percentile inside the FEMA area reached 80.04 m.
Why surface models score lower
GLO-30 is a digital surface model: its elevations include forest canopy over most of this basin. Canopy on valley floors adds height, canopy at the channel's edge moves the apparent channel, and 30 m cells blur narrow floodplains into their valley walls. Each adds error to HAND in exactly the cells that matter.
Edge cases or notes
- Coastal and tidal flooding is controlled by sea level, not river level; HAND does not apply.
- Flat deltas and plains have low HAND everywhere; cut-offs lose discrimination.
- Urban drainage follows pipes and streets; DEM streams and HAND miss it.
- Dams and reservoirs create flat water surfaces with zero HAND upstream.
- Variable cut-offs scaled by upstream area represent larger rivers' deeper floods better than one value.
- Validation data should be independent of the calibration data; FEMA zones used to choose a cut-off cannot also confirm it.
- Negative HAND means heights were measured on a different surface from the one routed; see calculating HAND in Python.
Internal links
- How to calculate height above nearest drainage (HAND) in Python โ the workflow and the pysheds trap
- How to extract a stream network from a DEM โ thresholds and networks
- How DEM resolution and source change a drainage network โ why GLO-30 differs
- DEM hydrology explained: from elevation to where water goes โ the routing HAND relies on
- LiDAR surfaces explained: DSM, DTM and CHM โ surface versus bare-earth models
- Flow direction explained: D8, D-Infinity and MFD compared โ the paths to nearest drainage
- How to burn a known river network into a DEM โ aligning channels before HAND
- How to rasterize a vector layer in Python โ flood zones as masks
FAQ
What is Height Above Nearest Drainage?
The elevation of each DEM cell above the stream cell its flow path reaches. Cells with low HAND lie little above their local channel and are candidates for flooding.
Is HAND a flood map?
No. It has no discharge, return period or water surface. Calibrated against FEMA zones in the Esopus basin, the best HAND zone captured 73.8% of the mapped area with a CSI of 0.578.
What HAND threshold indicates flooding?
It depends on the stream network, the DEM and the flood map. With 10 kmยฒ streams on a 10 m DEM, 5 m matched FEMA best here; calibrate locally.
Why does the stream network change HAND?
HAND is measured to the nearest stream. A dense network lowers HAND on small valley floors; a sparse one raises tributary floodplains to tens of metres above the main river.
Can I compute HAND from a 30 m global DEM?
Yes, but expect a weaker match. GLO-30, a surface model, scored lower than the 10 m bare-earth 3DEP DEM at every stream threshold tested.
What is HAND useful for?
Screening flood-prone land where no flood study exists, ranking sites by height above their stream, and as a terrain variable in statistical models.