Flow Routing Algorithms & Stream Network Extraction

Automated watershed delineation and hydrologic network derivation form the computational backbone of modern environmental modeling. This domain translates raw digital elevation models (DEMs) into hydrologically sound stream networks by chaining together terrain conditioning, directional routing, flow accumulation, and topological construction — all within reproducible Python pipelines that scale from laptop to cloud. The three principal algorithmic branches covered here — D8 flow direction, D-Infinity routing, and multiple-flow-direction methods — each occupy distinct positions in the accuracy-versus-speed trade-off, and choosing among them correctly is the first engineering decision any practitioner must make. Upstream of everything in this pipeline sits the terrain preparation work covered in Hydrology Data Preparation & DEM Processing, which must be complete before a single routing cell is computed. The extracted stream network then serves as the structural input to Watershed Delineation & Catchment Synchronization, where individual catchments are assigned pour points and assembled into multi-basin hierarchies.

Flow routing pipeline overview Five vertically-stacked stages of the flow routing pipeline: (1) Raw DEM ingestion with rasterio, (2) Hydrological conditioning — sink filling and flat resolution, (3) Algorithm selection branching into D8, D-Infinity, or MFD, (4) Flow accumulation and thresholding, (5) Stream network output as vector topology. Raw DEM Ingestion rasterio · CRS check · nodata masking Hydrological Conditioning sink filling · flat resolution · stream burning (opt.) D8 single steepest descent · richdem D-Infinity facet angle split TauDEM · richdem MFD slope-weighted dispersal · whitebox Flow Accumulation & Thresholding upstream cell count · calibrated channel threshold Stream Network vector topology · directed graph

Foundational Concepts

DEM Structure and Hydrologic Continuity

A DEM is a regular raster grid where each cell stores a single elevation value. For flow routing to work, that grid must form a connected surface with no ambiguous drainage paths. Two structural problems break this requirement:

  • Sinks (depressions): Cells lower than all neighbours that have no downslope outlet. Water accumulates there indefinitely instead of routing to the basin outlet.
  • Flat areas: Contiguous cells at identical elevation where no gradient exists to determine flow direction.

Both must be resolved before any routing algorithm is applied. The standard fix for sinks is priority-queue-based filling (the Planchon–Darboux approach), which raises each depression to its lowest spill-point elevation while preserving adjacent ridge geometry. Flat areas receive a synthetic gradient by combining distance-to-higher-terrain and distance-to-lower-terrain weighting, a method implemented in both richdem and whitebox as a single function call. The specifics of choosing between filling and breaching strategies are covered in DEM pit filling algorithms.

Flow Physics on a Raster Grid

Real overland flow is a continuous, three-dimensional process. Raster routing discretises it into per-cell decisions: where does water leaving this cell go? That discretisation introduces two fundamental constraints that practitioners must understand:

  1. Grid alignment bias. Diagonal cell connections are √2 longer than cardinal connections. Algorithms that ignore this (pure D8) underestimate flow path lengths on diagonal terrain and create parallel striping artefacts in flat areas.
  2. Single vs. dispersed routing. On convex slopes and divergent hillslopes, real water fans out across multiple downslope cells. Single-direction algorithms (D8) force 100% of flow into one neighbour, artificially concentrating it.

Understanding these constraints explains why algorithm choice matters: D8 is accurate where flow genuinely converges (incised channels, steep valleys); dispersal-based methods are necessary where flow genuinely fans out (pediments, alluvial fans, wetland fringes).

Watershed Topology

A delineated watershed is a directed acyclic graph (DAG) in which nodes are raster cells and edges represent flow connections. Stream order (Strahler or Shreve) is a graph-traversal property, not a raster property — it is computed after vectorisation. Outlet points anchor the graph: every cell in the contributing area drains to the outlet via the flow direction field. This topological framing has practical consequences:

  • Flow accumulation is a bottom-up graph traversal, not a pixel-by-pixel scan.
  • Stream network extraction is a threshold on node weight (upstream cell count or area).
  • Catchment delineation is a backwards graph traversal from a seed outlet.

Keeping this DAG model in mind prevents implementation mistakes such as applying accumulation before conditioning (which produces incorrect edge weights) or vectorising before closing topological gaps.


Algorithm Landscape

The three major routing families differ in how they partition flow from a source cell to its downslope neighbours.

D8: Single Steepest Descent

D8 routes 100% of a cell’s flow to whichever of its eight neighbours has the steepest downslope gradient. The algorithm is deterministic, produces acyclic networks, and executes in O(N) time after a sort step. It is the regulatory default for most US Army Corps of Engineers and USDA NRCS workflows.

Strengths: Speed, simplicity, guaranteed acyclicity, excellent for steep incised terrain.
Weaknesses: Artificial parallel drainage on low-relief terrain; underestimates hillslope lateral connectivity; grid-alignment bias distorts flow path lengths.

See D8 flow direction implementation for production code covering edge cases, encoding schemes, and flat-area handling with richdem.

D-Infinity (D∞): Facet-Based Continuous Routing

Tarboton’s D∞ method calculates flow direction as a continuous angle across the steepest triangular facet formed by each cell and two of its eight neighbours. Flow is then split between the two cells bounding that facet in proportion to how close the angle bisects them. This eliminates grid-alignment bias and produces smooth, non-parallel drainage patterns on convex slopes.

Strengths: Removes grid bias; better hillslope flow representation; widely used in research.
Weaknesses: More complex implementation; facet stability can degrade on very flat terrain; approximately 3–5× slower than D8 for large DEMs.

The D-Infinity routing patterns guide covers facet-angle computation, numerical stability on near-flat cells, and integration with TauDEM’s Python bindings.

Multiple-Flow Direction (MFD): Slope-Weighted Dispersal

MFD algorithms (Freeman 1991, Quinn et al. 1991) distribute flow proportionally across all downslope neighbours using slope-weighted partition coefficients. The partition exponent p controls dispersion: low values maximise spreading (suitable for overland flow); high values concentrate flow (approaching D8 behaviour). Some implementations — notably MD∞ — combine facet-based direction with multi-cell partitioning.

Strengths: Best physical representation of divergent flow; suited for soil moisture, diffuse pollution, and ecological habitat modelling.
Weaknesses: Cannot guarantee a strictly acyclic flow network without additional constraints; higher computational cost; threshold-based stream extraction is less stable than with D8 or D∞.

Multiple-flow-direction methods covers the partition-coefficient mathematics and shows how to suppress the flat-area artefacts that MFD amplifies relative to D8.

Algorithm Comparison

Property D8 D-Infinity MFD
Flow partition Single cell Two cells (angle split) All downslope cells
Grid bias High None Low
Convergent terrain Excellent Excellent Good
Divergent terrain Poor Good Excellent
Relative speed 1× (baseline) 3–5× slower 4–8× slower
Stream extraction Reliable Reliable Less stable
Regulatory acceptance Highest Growing Research/specialist
Memory footprint Low Low–Medium Medium–High

For terrain dominated by steep valleys and well-defined channels, D8 remains the pragmatic choice. For research applications requiring accurate hillslope flow paths, comparing D8 vs D-Infinity for steep terrain hydrology provides quantitative benchmarks to guide the decision.


Hydrological Conditioning Workflow

Before any routing algorithm runs, the DEM must pass through a conditioning pipeline. The sequence is non-negotiable: conditioning applied after routing produces incorrect results.

Hydrological conditioning pipeline Five stages of DEM conditioning shown as connected boxes: Raw DEM, Sink Filling, Flat Resolution, Stream Burning (optional, dashed border), and Conditioned DEM ready for routing. Raw DEM rasterio Sink Filling richdem / wbt Flat Resolution gradient inject Stream Burning optional Conditioned DEM

Step 1 — Sink filling. Use the Planchon–Darboux or Wang–Liu priority-flood algorithm. Both are available in richdem as rd.FillDepressions. For high-resolution LiDAR DEMs, breaching (carving a narrow outlet channel) often produces more realistic results than pure filling.

Step 2 — Flat-area resolution. rd.ResolveFlats assigns synthetic gradients to plateau cells, combining distance-to-higher and distance-to-lower weights. This step is mandatory before D8 and D∞; MFD is somewhat more tolerant but still benefits from it.

Step 3 — Stream burning (optional but recommended in managed catchments). Lower DEM cells along a known channel vector by a fixed offset (typically 2–10 m) to enforce drainage through culverts, bridges, or engineered channels that the DEM surface cannot represent.


Production Python Architecture

The pipeline below is end-to-end reproducible, includes logging throughout, and handles the tile-synchronisation edge case that breaks naive parallel implementations.

python
import logging
import numpy as np
import rasterio
from rasterio.crs import CRS
import richdem as rd

logging.basicConfig(
    level=logging.INFO,
    format="%(asctime)s [%(levelname)s] %(name)s — %(message)s",
)
log = logging.getLogger("flow_routing")


def condition_dem(src_path: str, dst_path: str) -> dict:
    """
    Fill sinks and resolve flat areas in a DEM.

    Parameters
    ----------
    src_path : str
        Path to the raw DEM (GeoTIFF, any projected CRS).
    dst_path : str
        Output path for the conditioned DEM.

    Returns
    -------
    dict
        Metadata dict including fill statistics.
    """
    log.info("Loading DEM from %s", src_path)
    with rasterio.open(src_path) as src:
        profile = src.profile.copy()
        nodata = src.nodata
        dem_np = src.read(1).astype(np.float64)
        transform = src.transform
        crs = src.crs

    if crs is None:
        raise ValueError("DEM has no CRS — assign one before conditioning.")
    log.info("DEM shape %s, CRS %s, nodata %s", dem_np.shape, crs, nodata)

    # Mask nodata before passing to richdem
    if nodata is not None:
        dem_np[dem_np == nodata] = np.nan

    dem_rd = rd.rdarray(dem_np, no_data=np.nan)
    dem_rd.geotransform = (
        transform.c, transform.a, transform.b,
        transform.f, transform.d, transform.e,
    )

    log.info("Filling depressions (Wang–Liu priority-flood)")
    filled = rd.FillDepressions(dem_rd, epsilon=True, in_place=False)

    filled_cells = int(np.sum(np.asarray(filled) > np.asarray(dem_rd)))
    log.info("Filled %d sink cells", filled_cells)

    log.info("Resolving flat areas")
    rd.ResolveFlats(filled, in_place=True)

    out_arr = np.asarray(filled)
    if nodata is not None:
        out_arr = np.where(np.isnan(out_arr), nodata, out_arr)

    profile.update(dtype=rasterio.float64, compress="lzw")
    with rasterio.open(dst_path, "w", **profile) as dst:
        dst.write(out_arr, 1)

    log.info("Conditioned DEM written to %s", dst_path)
    return {"filled_cells": filled_cells, "output": dst_path, "crs": str(crs)}


def compute_flow_direction_d8(conditioned_path: str, fd_path: str) -> None:
    """
    Compute D8 flow direction raster from a conditioned DEM.

    Output encoding: 1=E, 2=SE, 4=S, 8=SW, 16=W, 32=NW, 64=N, 128=NE.
    """
    log.info("Computing D8 flow direction from %s", conditioned_path)
    with rasterio.open(conditioned_path) as src:
        profile = src.profile.copy()
        nodata = src.nodata
        dem_np = src.read(1).astype(np.float64)
        transform = src.transform

    if nodata is not None:
        dem_np[dem_np == nodata] = np.nan

    dem_rd = rd.rdarray(dem_np, no_data=np.nan)
    dem_rd.geotransform = (
        transform.c, transform.a, transform.b,
        transform.f, transform.d, transform.e,
    )

    log.info("Running rd.FlowDirectionD8")
    fd = rd.FlowDirectionD8(dem_rd)

    profile.update(dtype=rasterio.int16, nodata=-1, compress="lzw")
    with rasterio.open(fd_path, "w", **profile) as dst:
        dst.write(np.asarray(fd).astype(np.int16), 1)
    log.info("Flow direction raster written to %s", fd_path)


def compute_flow_accumulation(fd_path: str, fa_path: str) -> None:
    """Compute upstream cell count from a D8 flow direction raster."""
    log.info("Computing flow accumulation from %s", fd_path)
    with rasterio.open(fd_path) as src:
        profile = src.profile.copy()
        fd_np = src.read(1).astype(np.float64)
        transform = src.transform

    fd_rd = rd.rdarray(fd_np, no_data=-1)
    fd_rd.geotransform = (
        transform.c, transform.a, transform.b,
        transform.f, transform.d, transform.e,
    )

    log.info("Running rd.FlowAccumulation (D8)")
    fa = rd.FlowAccumulation(fd_rd, method="D8")

    profile.update(dtype=rasterio.float64, nodata=-1, compress="lzw")
    with rasterio.open(fa_path, "w", **profile) as dst:
        dst.write(np.asarray(fa), 1)

    max_acc = float(np.nanmax(np.asarray(fa)))
    log.info("Flow accumulation complete. Max upstream cells: %d", int(max_acc))


def extract_stream_network(
    fa_path: str,
    stream_path: str,
    threshold: int = 5000,
) -> None:
    """
    Threshold flow accumulation to produce a binary stream mask.

    Parameters
    ----------
    threshold : int
        Minimum upstream cell count to classify a cell as a channel.
        Tune against NHD or field-mapped channel heads.
    """
    log.info(
        "Extracting streams from %s with threshold=%d cells",
        fa_path,
        threshold,
    )
    with rasterio.open(fa_path) as src:
        profile = src.profile.copy()
        fa_np = src.read(1)

    stream_mask = (fa_np >= threshold).astype(np.uint8)
    channel_cells = int(stream_mask.sum())
    log.info("Stream mask contains %d channel cells", channel_cells)

    profile.update(dtype=rasterio.uint8, nodata=0, compress="lzw")
    with rasterio.open(stream_path, "w", **profile) as dst:
        dst.write(stream_mask, 1)
    log.info("Stream mask written to %s", stream_path)

For larger DEMs, replace the monolithic reads with tile-aware loops that write intermediate tiles to disk using rasterio.windows.Window, then merge. See the scaling section below for the Dask pattern.


Flow Accumulation and Stream Thresholding

Flow accumulation assigns each cell a value equal to the number of upstream cells that drain through it (or, with area-weighted variants, the total contributing area in physical units). The resulting raster is a topographic “skeleton” — high-accumulation ridges trace channels, while low-accumulation cells represent hillslopes.

The transition from continuous accumulation to a discrete binary channel network requires a threshold. This is the most consequential calibration decision in the pipeline:

  • Too low: Over-delineated networks with spurious headwater channels in agricultural fields, forest clearcuts, or road cuts.
  • Too high: Under-delineated networks that miss real ephemeral streams and misplace watershed divides.

Stream threshold tuning provides iterative calibration techniques using NHD reference reaches and geomorphic indices (drainage density, channel head gradient). For ephemeral and intermittent systems, tuning flow accumulation thresholds for ephemeral streams shows how to adapt cutoffs to arid and semi-arid hydroclimatic regimes.


Scaling and Cloud-Native Workflows

High-resolution DEMs (1 m LiDAR, continental 10 m 3DEP) exceed available RAM on standard workstations. Three strategies address this:

Tile-Based Processing with Edge Synchronisation

Split the DEM into overlapping tiles, process each independently, then trim to the non-overlapping core before writing. The overlap (halo) width must be at least as large as the maximum expected drainage basin diameter at the tile boundary — in practice, 50–200 cells for 10 m DEMs.

python
import rasterio
from rasterio.windows import Window
import logging

log = logging.getLogger("tile_routing")

def process_tiles(src_path: str, tile_size: int = 2048, halo: int = 128):
    """
    Iterate over overlapping tiles of a DEM for memory-bounded processing.
    Yields (window, data_array) tuples for downstream per-tile routing.
    """
    with rasterio.open(src_path) as src:
        width, height = src.width, src.height
        log.info(
            "Tiling %dx%d raster into %dx%d tiles with %d-cell halo",
            width, height, tile_size, tile_size, halo,
        )
        for row_off in range(0, height, tile_size):
            for col_off in range(0, width, tile_size):
                # Expand to halo, clamp to raster bounds
                r0 = max(0, row_off - halo)
                c0 = max(0, col_off - halo)
                r1 = min(height, row_off + tile_size + halo)
                c1 = min(width, col_off + tile_size + halo)
                win = Window(c0, r0, c1 - c0, r1 - r0)
                data = src.read(1, window=win)
                log.debug("Tile window %s, shape %s", win, data.shape)
                yield win, data

Dask and Cloud-Optimized GeoTIFF

dask.array enables lazy out-of-core accumulation when combined with rioxarray. Cloud-Optimized GeoTIFFs (COGs) allow rasterio to fetch only the tiles needed for a given spatial query without downloading the full file. Zarr stores provide comparable streaming capability for multi-temporal accumulation stacks.

python
import dask.array as da
import rioxarray

def lazy_flow_accumulation(cog_path: str, chunk_size: int = 1024):
    """
    Load a COG as a lazy Dask array for out-of-core accumulation.
    """
    log.info("Opening COG lazily: %s", cog_path)
    ds = rioxarray.open_rasterio(cog_path, chunks={"x": chunk_size, "y": chunk_size})
    log.info("Lazy array shape: %s, dtype: %s", ds.shape, ds.dtype)
    return ds

HPC and Cloud Orchestration

For regional or continental-scale runs, containerise the pipeline with Docker (pinning richdem, rasterio, and whitebox versions) and orchestrate with Apache Airflow or Prefect. Key considerations:

  • Use Zarr-backed intermediate stores on S3 or GCS to share state across distributed workers.
  • Assign catchment tiles to workers by topological order — tiles must process headwater cells before outlet cells to avoid accumulation race conditions.
  • Profile memory per tile before deploying at scale: a 2048×2048 float64 tile occupies ~32 MB; accumulation intermediates can double this.

Validation and QA/QC

Automated extraction requires systematic verification before the output is used in regulatory or modelling workflows.

Visual checks:

Topological checks:

Statistical checks:

Datum and projection checks:


Common Failure Modes

Memory exhaustion on large DEMs.
Root cause: loading a full 10 m continental DEM (~40 GB uncompressed) into RAM before routing.
Mitigation: switch to tile-based processing with the halo pattern shown above, or use richdem’s streaming mode via rd.LoadGDAL with no_data masking applied at load time.

Flat-area stagnation (D8 produces no-flow zones).
Root cause: sink filling creates perfectly flat surfaces where D8 cannot determine a direction.
Mitigation: always run rd.ResolveFlats after rd.FillDepressions; do not apply D8 to a raw-filled DEM. Removing flat-area artefacts from flow direction grids covers the resolution algorithm in detail.

Projection artefacts (diagonal flow bias in geographic CRS).
Root cause: routing a DEM stored in geographic coordinates (degrees latitude/longitude) treats diagonal and cardinal neighbours as equidistant.
Mitigation: reproject to a projected CRS (e.g., UTM or Albers Equal Area) before routing. Use rasterio.warp.reproject with bilinear resampling for the DEM, nearest-neighbour for any derived integer rasters.

Stream burning over-correction.
Root cause: burning depth set too aggressively (>10 m) causes channelisation to “capture” adjacent hillslope cells.
Mitigation: limit burn depth to 2–5 m for most applications; verify by overlaying the burned DEM profile against the original elevation along channel transects.

Threshold instability across tiles.
Root cause: a fixed accumulation threshold implies different physical areas depending on cell size — if tile edges have slightly different resampling, discontinuities appear in the network.
Mitigation: compute threshold in physical area units (km²) and convert to cells using the actual cell resolution retrieved from rasterio.DatasetReader.res.

NaN propagation from nodata cells.
Root cause: nodata values (-9999 or similar) interpreted as valid elevations poison accumulation calculations across entire basins.
Mitigation: always pass no_data explicitly to rd.rdarray; assert np.isnan(dem_np).sum() == 0 after masking and before routing.


Frequently Asked Questions

Which flow routing algorithm should I use for steep mountain watersheds?

D8 performs well in steep, incised terrain where flow convergence dominates — the single-direction assumption matches physical reality when channels are deeply incised and flow paths are unambiguous. D-Infinity is preferred when divergent flow on ridgelines and upper hillslopes matters, such as when mapping snow melt pathways or modelling shallow landslide source areas. MFD is best reserved for hillslope moisture modeling and diffuse pollution transport rather than channel delineation, where its dispersal behaviour is a feature rather than a limitation.

How do I choose a flow accumulation threshold?

Calibrate against mapped hydrography — stream threshold tuning describes the full iterative process. Start with a threshold that produces drainage densities within ±15% of NHD reference data for your region, then adjust for DEM resolution (coarser DEMs require lower cell-count thresholds to match the same physical area), hydroclimatic regime (arid catchments have intrinsically lower drainage density), and land cover (disturbed catchments with compacted soils develop denser networks). Always express the final threshold in physical area units (km²) rather than cell counts so it remains portable across DEM resolutions.

Can I run flow routing on 1-metre LiDAR DEMs without running out of memory?

Yes, using tile-based processing with edge synchronisation. Split the DEM into overlapping tiles (2048×2048 cells with a 128-cell halo works well for most routing algorithms), route each tile independently, trim to the non-overlapping core, then merge. Dask arrays combined with Cloud-Optimized GeoTIFF formats make this practical even on standard workstations. For continental-scale runs, use Zarr-backed intermediate stores on cloud object storage and assign tiles to workers by topological order to avoid accumulation race conditions.

Why does my stream network have parallel diagonal stripes on flat terrain?

This is the grid-alignment bias of D8 on low-relief terrain. When all cells in a flat region drain in a single direction determined by the nearest topographic low, D8 produces a staircase pattern of parallel diagonal channels. The solution is thorough flat-area resolution using rd.ResolveFlats before routing, which injects a synthetic gradient that fans flow outward from higher ground and inward toward drainage outlets. If the artefact persists, consider switching to D-Infinity or MFD for the flat portions of the domain, as covered in removing flat-area artefacts from flow direction grids.