Airflow DAG Templates for Batch Watershed Delineation

Apache Airflow’s TaskFlow API expresses the DEM-to-watershed chain as a scheduled DAG that fans across a list of hydrologic unit codes at runtime, making it the natural fit when watershed delineation runs on a cadence across many basins rather than on demand for one. This template guide sits under pipeline orchestration, part of the Watershed Delineation & Catchment Synchronization section, and focuses on the Airflow-specific machinery: a @dag schedule, dynamic task mapping with .expand, XCom that carries paths rather than payloads, and a @task.sensor that blocks the batch until the input DEM has landed.

Airflow’s strength for this workload is operational. Every mapped basin appears as its own task instance in the grid view, retryable and inspectable in isolation, with batch-wide concurrency governed by max_active_tasks and pool slots. That is the scheduling model an operations team already running Airflow will reach for; the contrast with Prefect’s in-flow model is spelled out in the sibling guide.


Prerequisites

  • Apache Airflow 2.8 or newer for the mature TaskFlow API and dynamic task mapping, with the geospatial stack (richdem, rasterio, pysheds, geopandas) installed in the same environment the workers run under.
  • A metadata database (Postgres in production; SQLite only for local trials) — every XCom value lands here, which is exactly why you pass paths, not arrays.
  • Shared storage reachable by every worker — a mounted volume or object store — so a path written by one task is readable by the next regardless of which worker picks it up.
bash
pip install "apache-airflow>=2.8"
airflow db migrate       # initialise the metadata database before first run

DAG Structure and Scheduling

The @dag decorator turns a function into a DAG; @task functions inside it become nodes, and returning a value from one into another wires the dependency and routes the value through XCom. Scheduling parameters live on the decorator.

Airflow Batch Delineation DAG with Dynamic Mapping A DEM-arrival sensor on the left feeds a list-basins task. That task expands into three mapped per-basin delineation pipelines running in parallel, each labelled process HUC. All three feed into a single collect-results task on the right. An annotation notes the schedule is daily, mapping uses dot expand, and concurrency is capped by max_active_tasks. wait_for_dem @task.sensor list_basins returns HUC list process HUC[0] .expand process HUC[1] .expand process HUC[2] .expand collect results schedule=@daily · concurrency via max_active_tasks

The sensor gates the whole batch: nothing downstream runs until the input DEM is present. Once it succeeds, list_basins returns the HUC list and the per-basin pipeline expands into one task instance per basin, all subject to the concurrency cap.


Waiting for Input with @task.sensor

A scheduled batch should not begin the moment the clock fires — it should begin when the day’s DEM has actually arrived on shared storage. The @task.sensor decorator turns a function into a deferrable sensor that polls until its PokeReturnValue reports done.

python
import logging
from pathlib import Path
from airflow.decorators import task
from airflow.sensors.base import PokeReturnValue

logging.getLogger("airflow.task").setLevel(logging.INFO)


@task.sensor(poke_interval=300, timeout=60 * 60 * 6, mode="reschedule")
def wait_for_dem(dem_dir: str) -> PokeReturnValue:
    """Poll shared storage every 5 minutes (up to 6 hours) for the day's DEM drop.
    mode='reschedule' frees the worker slot between pokes instead of holding it."""
    log = logging.getLogger("airflow.task")
    ready_flag = Path(dem_dir) / "_SUCCESS"
    is_ready = ready_flag.exists()
    log.info("Poking for DEM readiness flag %s: %s", ready_flag, is_ready)
    return PokeReturnValue(is_done=is_ready, xcom_value=str(dem_dir))

mode="reschedule" is the important choice: it releases the worker slot between pokes so a six-hour wait does not occupy a slot the whole time, which matters when many DAGs share a worker pool. The sensor returns the DEM directory as its XCom value so downstream tasks receive the confirmed path.


Dynamic Task Mapping with .expand

.expand generates one mapped task instance per element of a list produced at runtime — the batch size is not fixed in the DAG definition but discovered when list_basins runs. Each mapped instance is independently scheduled, retried, and shown in the grid view.

python
import logging
import pendulum
from airflow.decorators import dag, task

logging.getLogger("airflow.task").setLevel(logging.INFO)


@dag(
    schedule="@daily",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    max_active_tasks=4,          # cap concurrent RichDEM stages to fit worker RAM
    default_args={"retries": 2, "retry_delay": pendulum.duration(minutes=5)},
    tags=["hydrology", "delineation"],
)
def batch_watershed_delineation():
    """Scheduled batch: wait for the DEM drop, list basins, delineate each, collect."""

    @task
    def list_basins(dem_dir: str) -> list[dict]:
        """Read the day's basin manifest and return one dict per HUC to map over."""
        log = logging.getLogger("airflow.task")
        basins = [
            {"huc_id": "03010101", "dem_dir": dem_dir, "outlet_xy": [1_450_000.0, 1_920_000.0]},
            {"huc_id": "03010102", "dem_dir": dem_dir, "outlet_xy": [1_462_500.0, 1_905_000.0]},
            {"huc_id": "03010103", "dem_dir": dem_dir, "outlet_xy": [1_478_000.0, 1_888_500.0]},
        ]
        log.info("Discovered %d basins for this run", len(basins))
        return basins

    @task
    def delineate_basin(params: dict) -> str:
        """Full per-basin pipeline. Reads the DEM path from params, writes a catchment
        file to shared storage, and returns ONLY its path via XCom (never the raster)."""
        log = logging.getLogger("airflow.task")
        huc_id = params["huc_id"]
        dem_path = f"{params['dem_dir']}/{huc_id}/dem_raw.tif"
        # fill + route (RichDEM) and snap + delineate (pysheds) run here on dem_path.
        out_path = f"{params['dem_dir']}/{huc_id}/catchment.gpkg"
        log.info("Delineated %s -> %s", huc_id, out_path)
        return out_path      # a path string: small, XCom-safe

    @task
    def collect_results(catchment_paths: list[str]) -> None:
        """Gather the mapped outputs (paths) for downstream cataloguing."""
        log = logging.getLogger("airflow.task")
        log.info("Batch produced %d catchments", len(catchment_paths))

    dem_dir = wait_for_dem(dem_dir="/data/dem_drop")
    basins = list_basins(dem_dir)
    catchments = delineate_basin.expand(params=basins)   # one instance per basin
    collect_results(catchments)


batch_watershed_delineation()

delineate_basin.expand(params=basins) is the whole mechanism: Airflow reads the list list_basins produced and materializes a mapped instance per element, each running delineate_basin with one basin’s parameters. collect_results receives the list of returned paths automatically once every mapped instance finishes. The per-basin body condenses the same fill, route, snap, and delineate operations detailed in the parent orchestration guide.


Parameter Reference

Parameter Where it goes Effect
schedule @dag Cadence of the batch, e.g. @daily or a cron expression
catchup @dag False skips backfilling missed intervals on first deploy
max_active_tasks @dag Ceiling on concurrent task instances; size to worker memory
retries / retry_delay default_args Automatic re-execution policy applied to every task
.expand(param=list) task call Dynamic mapping — one mapped instance per list element
poke_interval / mode @task.sensor Polling cadence; reschedule frees the slot between pokes

Gotchas

  • XCom carries paths, not arrays. Every value returned from a task is serialized into the metadata database, which is sized for small control-plane payloads. Returning a NumPy raster bloats the database and can exceed the backend’s XCom size limit outright. Write the raster to shared storage and return only its path string, as every task above does; the next task opens the file itself.

  • Tasks must be idempotent for retries and backfill. Airflow retries a failed mapped instance and may re-run a scheduling interval. A task that appends to a shared file or writes to a timestamp-only path duplicates records on rerun. Key every output on the HUC identifier and write to a deterministic path so a retry overwrites cleanly rather than accumulating.

  • Executor choice governs memory contention. The LocalExecutor runs tasks as subprocesses on one node — fine for a powerful, high-RAM machine and a modest basin count. The CeleryExecutor or KubernetesExecutor distribute mapped instances across workers for large batches. Whichever you pick, RichDEM’s resident arrays are the constraint: cap max_active_tasks and use pools so concurrent DEMs never exhaust a worker.

  • catchup=False on first deploy. A DAG with a past start_date and catchup=True immediately backfills every missed interval, launching a flood of batch runs. Set catchup=False unless you genuinely want the history reprocessed.


Frequently Asked Questions

Why should Airflow XCom carry file paths instead of raster arrays?

XCom values are serialized into the Airflow metadata database, which is sized for small control-plane payloads, not gigabyte NumPy arrays. Pushing a raster through XCom bloats the database and can exceed the backend’s size limit. Write the raster to shared storage and pass only its path through XCom; the next task reads the file itself. Every task in the template above returns a path string for exactly this reason.

How does .expand differ from Prefect’s .map()?

Both fan a single task definition across a list at runtime. Airflow’s .expand creates one mapped task instance per element in the scheduler, each individually visible and retryable in the grid view, with concurrency governed by max_active_tasks and pool slots. Prefect’s .map() submits futures to a task runner inside a single flow run. .expand is scheduler-native and better suited to long-running batch jobs that an operations team monitors, which is the core contrast between the two frameworks for this workload.

Which Airflow executor suits batch watershed delineation?

The LocalExecutor handles a single powerful node with abundant RAM, which suits memory-hungry RichDEM stages when the basin count is modest. For large batches spread across machines, the CeleryExecutor or KubernetesExecutor distribute mapped task instances to workers. Whichever you choose, cap parallelism with max_active_tasks and pools so concurrent DEMs do not exhaust worker memory and trigger cascading retries.