Education › Data › Stage 2: Pipelines

Orchestration with DAGs

Airflow-style scheduling, dependencies, retries, sensors, backfills and keeping DAGs boring.

Intermediate ~35 min read Module 6 of 16

A cron entry runs a script at 02:00. It does not know that the script depends on a file that arrives at 02:15, that yesterday's run failed and today's should wait, that three other scripts need this one's output, or that someone needs to rerun last week. An orchestrator knows all of that. It models work as a directed acyclic graph of tasks, schedules it by logical date, retries what fails, records every run, and gives you one place to see what is late and why. This module uses Apache Airflow's vocabulary — the most widely deployed orchestrator — but the ideas apply to Dagster, Prefect and the managed equivalents: DAGs, tasks, dependencies, scheduling semantics, retries, sensors, backfills and the discipline of keeping DAGs boring.

After this module you can
  • Model a pipeline as a DAG with clear task boundaries and explicit dependencies
  • Explain scheduling semantics: logical date, data intervals, catch-up and backfill
  • Configure retries, timeouts, SLAs and alerts so failures are handled without a human at 3 a.m.
  • Use sensors and datasets to wait for inputs without polling loops in code
  • Keep DAG code thin, testable and idempotent, with heavy work delegated to the right engine

DAGs and tasks

A DAG (directed acyclic graph) is a set of tasks and the dependencies between them: extract must finish before transform, transform before publish, and publish fans out to three downstream notifications. Acyclic means no loops, so the orchestrator can always determine an order. Each task is one unit of work with a clear input and output that can be retried on its own. Good task boundaries follow the pipeline stages from the previous module: extract, stage, transform, validate, publish. A single task that does everything cannot be retried partially or observed meaningfully.

An Airflow DAG for the daily orders pipeline. Each task is small, parameterised by the run's data interval, and delegates real work to a function or an external engine.
python
from datetime import datetime, timedelta

from airflow.decorators import dag, task
from airflow.providers.common.sql.operators.sql import SQLExecuteQueryOperator


@dag(
    dag_id="orders_daily",
    schedule="0 3 * * *",                 # 03:00 UTC, after the source's nightly close
    start_date=datetime(2026, 1, 1),
    catchup=False,
    max_active_runs=1,
    default_args={"retries": 3, "retry_delay": timedelta(minutes=5), "retry_exponential_backoff": True,
                  "execution_timeout": timedelta(minutes=30), "owner": "data-platform"},
    tags=["orders", "daily"],
)
def orders_daily():
    @task
    def extract(data_interval_start=None, data_interval_end=None) -> str:
        from pipelines.orders import extract_to_raw
        return extract_to_raw(start=data_interval_start, end=data_interval_end)   # returns the raw path

    @task
    def validate_raw(path: str) -> str:
        from pipelines.orders import check_raw
        check_raw(path)                     # raises on empty file or schema drift
        return path

    transform = SQLExecuteQueryOperator(
        task_id="transform",
        conn_id="warehouse",
        sql="sql/orders_transform.sql",   # uses {{ data_interval_start }} for the partition
    )

    @task
    def publish_metrics(**context):
        from pipelines.orders import emit_run_metrics
        emit_run_metrics(run_id=context["run_id"])

    validate_raw(extract()) >> transform >> publish_metrics()


orders_daily()
ORDERS_DAILY RUN: DATA_INTERVAL_START = 2026-03-01file presentpath onlyokwritesrunsupdatestriggerswait_for_filedeferrable, timeoutextractretries 3, backoffvalidate_rawretries 0transformSQL in warehousepublishoutlet: datasetraw/dt=03-01object storageWarehousedoes the heavy worksilver/ordersDataset updatedorders_reportingschedule=[Dataset]
One run of the orders DAG for the March 1st interval: a sensor waits for the input, then extract, validate, transform and publish run in dependency order, each one parameterised by the same logical date, retried on transient failure, and delegating heavy work to the warehouse. The reporting DAG starts when the produced dataset updates, not on a guessed time.

Notice what the DAG does not do: it does not contain the transformation logic, it does not compute dates from the clock, and it does not hold data in memory between tasks. Tasks pass small references (a path, a partition key), never DataFrames. The heavy lifting runs in the warehouse, in a Spark job, in a container — anywhere but the orchestrator's own workers, which exist to coordinate.

Scheduling semantics: the logical date

The most misunderstood part of any orchestrator is when a run happens versus which data it covers. A daily DAG scheduled at 03:00 with a data interval of one day runs at 03:00 on March 2nd to process the interval March 1st 00:00 to March 2nd 00:00. The run's logical date (data_interval_start) is March 1st, even though the clock says March 2nd. Every task should derive its target partition from the interval it is given, never from datetime.now(). This is what makes reruns and backfills correct: rerunning the March 1st run in April still processes March 1st.

SettingMeaningTypical choice
scheduleCron or interval that defines data intervals0 3 * * * for daily after source close
start_dateFirst data interval to considerA fixed date in the past, never now()
catchupWhether to run every missed interval since start_date on deployFalse unless you want history built automatically
max_active_runsHow many intervals may run concurrently1 for pipelines whose runs depend on order
depends_on_pastWait for the previous interval's task to succeedTrue for incremental loads that assume continuity
Watch out

Setting start_date to a dynamic value or leaving catchup=True on a DAG with an old start date are the two classic first-day surprises: the first never schedules stably, the second launches hundreds of runs the moment the DAG is enabled.

Failure handling without a human

Tasks fail for transient reasons (a network blip, a warehouse queue) and for real ones (bad data, a bug). Retries with backoff absorb the first kind; a task that is idempotent can be retried safely, which is why idempotency kept coming up. Timeouts stop a hung task from blocking the day. When retries are exhausted, the failure should reach a human with context: which DAG, which interval, which task, the log, and a runbook link — through an on-failure callback to the alerting system, not through someone checking the UI. SLAs (or freshness checks on the output tables) catch the failure mode retries do not: the run that succeeds late, or never starts.

Failure and lateness reaching people with context: an on-failure callback and a per-task SLA.
python
from datetime import timedelta


def page_on_failure(context):
    from pipelines.alerts import send_alert
    ti = context["task_instance"]
    send_alert(
        severity="high",
        title=f"{ti.dag_id}.{ti.task_id} failed for {context['data_interval_start']:%Y-%m-%d}",
        body=f"attempt {ti.try_number} of {ti.max_tries + 1}; log: {ti.log_url}",
        runbook="https://runbooks.internal/data/orders_daily",
    )


default_args = {
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "retry_exponential_backoff": True,
    "execution_timeout": timedelta(minutes=30),
    "on_failure_callback": page_on_failure,
    "sla": timedelta(hours=2),              # task should finish within 2 h of the interval's end
}

Decide per task whether downstream should run when it fails. The default (skip downstream) is right for a hard dependency; for optional enrichment a trigger rule such as all_done lets the pipeline continue and flag the gap. And separate "the data is bad" from "the task crashed": a validation task that fails on bad data should not be retried three times; it should stop the run and alert with the validation report.

Waiting for inputs: sensors and datasets

Pipelines wait: for a file to land, for an upstream DAG to finish, for a partition to appear in the warehouse. A sensor is a task that polls for a condition and succeeds when it holds, with a timeout so it does not wait forever. Use deferrable (or reschedule mode) sensors so a waiting task does not occupy a worker slot. Better than polling, where the platform supports it, is data-aware scheduling: a DAG declares that it produces a dataset, downstream DAGs declare they consume it, and the scheduler starts the consumer when the producer updates the dataset — no cron guessing about when upstream will be done.

A deferrable file sensor with a timeout, and dataset-driven scheduling so the downstream DAG runs exactly when its input is ready.
python
from datetime import datetime, timedelta

from airflow.datasets import Dataset
from airflow.decorators import dag, task
from airflow.providers.amazon.aws.sensors.s3 import S3KeySensor

ORDERS_SILVER = Dataset("s3://acme-lake/silver/orders/")


@dag(dag_id="partner_file_ingest", schedule="0 4 * * *", start_date=datetime(2026, 1, 1), catchup=False)
def partner_file_ingest():
    wait_for_file = S3KeySensor(
        task_id="wait_for_partner_file",
        bucket_name="acme-inbound",
        bucket_key="partner/orders_{{ data_interval_start | ds_nodash }}.csv",
        deferrable=True,                       # frees the worker while waiting
        timeout=6 * 60 * 60,                   # give up after 6 hours and alert
        poke_interval=300,
    )

    @task(outlets=[ORDERS_SILVER])
    def load_to_silver():
        from pipelines.partner import load
        load()

    wait_for_file >> load_to_silver()


@dag(dag_id="orders_reporting", schedule=[ORDERS_SILVER], start_date=datetime(2026, 1, 1), catchup=False)
def orders_reporting():
    @task
    def build_reports():
        from pipelines.reports import build
        build()

    build_reports()


partner_file_ingest()
orders_reporting()
Note

Newer Airflow versions call datasets "assets" and Dagster is built around the asset idea from the start: you declare the tables you produce and their dependencies, and the schedule follows. The concept is the same — schedule on data readiness, not on the clock.

Backfills and keeping DAGs boring

A backfill asks the orchestrator to run a DAG for a range of past intervals. Because each run gets its own logical date and every task is idempotent, the backfill is just many correct runs. Limit its concurrency so it does not starve the daily schedule or overwhelm the warehouse; clear only the tasks that need rerunning rather than the whole DAG; and watch the first few runs before letting the rest go. Backfills that need special handling (a different code path, skipped notifications) should get an explicit parameter, not a guess based on how old the interval is.

Operating a DAG from the CLI: a backfill with bounded concurrency, clearing a failed task, and testing one task locally before deploying.
bash
# rebuild a range with at most 2 intervals in flight, without triggering downstream datasets
airflow dags backfill orders_daily --start-date 2026-02-01 --end-date 2026-02-28 --max-active-runs 2

# rerun just the transform (and what depends on it) for one interval
airflow tasks clear orders_daily --task-regex '^transform$' --downstream \
  --start-date 2026-03-01 --end-date 2026-03-01 --yes

# run one task for one interval locally, no scheduler needed
airflow tasks test orders_daily extract 2026-03-01
  • Thin DAG files: imports inside tasks, logic in a package with unit tests; the DAG file is parsed constantly and must be fast and side-effect free.
  • No top-level work: no database calls or API requests at import time; they run every parse.
  • Small payloads between tasks: paths and keys, not data; the metadata database is not a data store.
  • One DAG per pipeline, one owner, tagged: hundreds of tasks in one DAG is hard to operate; hundreds of copy-paste DAGs is worse. Generate similar DAGs from configuration.
  • Version the DAGs with the pipeline code and deploy through CI like any other code.

The orchestrator's job is to make the pipeline's behaviour predictable and visible. When the DAG is thin, the tasks idempotent and the schedule data-aware, the interesting logic lives where it is tested, and the orchestrator becomes what it should be: the boring, reliable clock and ledger of the platform.

Hands-on practice

Orchestrate the pipeline from the previous module

  1. Install Airflow locally in standalone mode (airflow standalone) or with the official Docker Compose file. Confirm the web UI loads and the example DAGs are disabled.
  2. Wrap the idempotent orders pipeline from the batch module as a DAG with four tasks: extract, validate, transform, publish. Pass only the raw path between tasks. Use data_interval_start for the partition.
  3. Set catchup=False, enable the DAG, trigger one run manually, and read each task's log. Then run airflow tasks test for the extract task with a past date and confirm it processed that date.
  4. Make the validate task fail on an empty file, wire an on-failure callback that prints a formatted alert, and confirm retries are not attempted for validation failures (set retries=0 on that task).
  5. Add an S3 (or local filesystem) sensor with a 10-minute timeout in front of extract, in deferrable or reschedule mode, and watch it wait and then succeed when you create the file.
  6. Split reporting into a second DAG scheduled on a Dataset produced by the first, and confirm it runs automatically after the first completes.
  7. Backfill one week with --max-active-runs 2, then clear and rerun a single task for one day. Check the warehouse partitions are correct after each.
Cheat sheet

Orchestration with DAGs — at a glance

Main things to focus on

  • A DAG is tasks with explicit dependencies; each task is a retryable unit with a clear input and output
  • Logical date = the data interval the run covers, not when it runs; derive partitions from it, never from now()
  • Fixed start_date, catchup=False unless history is wanted, max_active_runs=1 for ordered pipelines
  • Retries with backoff for transient failures; timeouts; on-failure callbacks with context; SLAs or freshness checks for lateness
  • Deferrable sensors with timeouts to wait; datasets/assets to schedule on data readiness
  • Thin DAG files, no top-level work, small payloads, logic in a tested package, backfills with bounded concurrency

DAG definition

@dag(dag_id, schedule, start_date, catchup, max_active_runs, default_args, tags)The header that sets behaviour
@task def f(data_interval_start=None): ...Python task receiving the interval
a >> b >> [c, d]Dependencies; fan-out with lists
SQLExecuteQueryOperator(conn_id, sql='file.sql')Run SQL in the warehouse; template dates
{{ data_interval_start | ds }}Jinja: the logical date as YYYY-MM-DD
import inside the task functionFast, side-effect-free parsing

Scheduling

schedule='0 3 * * *'Cron; run at 03:00 for the previous day's interval
schedule=timedelta(hours=1)Interval schedule
schedule=[Dataset(uri)]Run when the dataset is updated
catchup=FalseDo not run missed intervals on enable
depends_on_past=TrueSerial runs for continuity-dependent loads
data_interval_start / data_interval_endThe window this run owns

Failure handling

retries=3, retry_delay, retry_exponential_backoff=TrueAbsorb transient failures
execution_timeout=timedelta(minutes=30)No hung tasks
on_failure_callback=fn(context)Alert with DAG, task, interval, log URL, runbook
sla=timedelta(hours=2) / output freshness checkCatch late or missing runs
trigger_rule='all_done'Continue past optional failures
retries=0 on validation tasksBad data is not transient

Waiting and operating

S3KeySensor(..., deferrable=True, timeout=..., poke_interval=...)Wait without holding a worker
@task(outlets=[Dataset(uri)])Declare what a task produces
airflow dags backfill DAG --start-date --end-date --max-active-runs NBounded reprocessing
airflow tasks clear DAG --task-regex R --downstream --start-date --end-dateRerun part of a run
airflow tasks test DAG TASK DATERun one task locally
XCom: paths and keys onlyNever pass data between tasks

Common pitfalls

  • Computing the date to process from the clock inside a task; backfills process the wrong day.
  • Dynamic start_date or catchup left True on an old start date; hundreds of surprise runs.
  • Heavy work on the orchestrator's workers and DataFrames in XCom; the scheduler becomes the bottleneck.
  • Database or API calls at DAG file top level; they run on every parse.
  • Retrying validation failures as if bad data were a network blip.
  • Sensors in poke mode with no timeout, silently occupying every worker slot.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →