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.

Two panels contrasting elevation, which ranks valley floors by altitude, with HAND, which measures each cell against the stream it drains to so that valley floors at any altitude are near zero.
Elevation compares cells with sea level; HAND compares them with their own river.

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.

Bar chart of the best critical success index against the FEMA flood hazard area for HAND from streams at 0.1, 1, 10 and 50 square kilometres, on the 3DEP 10 m DEM and the GLO-30 30 m DEM.
The network definition moved the score more than the choice between a 10 m and a 30 m DEM.

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.

Table of median and 90th percentile HAND inside FEMA zone AE, zone A, the 0.2 percent annual chance zone and outside all zones, from 10 square kilometre streams on the 3DEP DEM.
The 0.2% zone sat more than twice as high above drainage as the detailed AE zone.

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.

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.