Appendix D: Airflow Reference

Pinned to Apache Airflow 2.10.5 (Appendix A). §D.11 covers what changes in Airflow 3.

The concepts matter more than the API, and §D.1 is the one that produces the most bugs.


D.1 Logical Date, Data Interval, and Wall Clock

Three different times, and confusing them is the most common Airflow bug.

a DAG with schedule="0 2 * * *" (02:00 daily), run on 15 November

data_interval_start   2026-11-14 00:00   the data this run is responsible for
data_interval_end     2026-11-15 00:00
logical_date          2026-11-14 00:00   == data_interval_start
wall clock            2026-11-15 02:00   when it actually executed

The run at 02:00 on the 15th processes the 14th. That is deliberate: a daily interval is not complete until it ends.

Every task must key on the interval, never on now():

@task
def load(data_interval_start=None, data_interval_end=None):
    # correct: a rerun of this run processes the same window
    extract(since=data_interval_start, until=data_interval_end)

@task
def load_wrong():
    extract(since=datetime.now() - timedelta(days=1))   # a rerun gets different data

This is what makes a rerun idempotent (ch20, ch24), and it is why "clear and rerun" is safe.


D.2 A DAG, Current Style

from airflow.decorators import dag, task
from airflow.datasets import Dataset
from pendulum import datetime

ORDERS = Dataset("s3://bronze/orders")

@dag(
    dag_id="kestrel_daily",
    schedule="0 2 * * *",
    start_date=datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    max_active_runs=1,
    default_args={
        "retries": 3,
        "retry_delay": timedelta(minutes=5),
        "retry_exponential_backoff": True,
        "max_retry_delay": timedelta(minutes=30),
        "owner": "data-platform",
    },
    tags=["kestrel", "daily"],
    doc_md=__doc__,
)
def kestrel_daily():

    @task(outlets=[ORDERS])
    def extract_orders(data_interval_start=None, data_interval_end=None):
        ...

    @task
    def transform():
        ...

    @task(trigger_rule="none_failed_min_one_success")
    def publish():
        ...

    extract_orders() >> transform() >> publish()

kestrel_daily()

D.3 The Settings That Matter

Setting Why
catchup=False otherwise unpausing runs every missed interval
max_active_runs=1 stops two runs writing the same partition
retries + backoff a transient failure should not page anybody
execution_timeout a hung task holds a slot forever without it
sla records a miss; does not stop the task
pool bounds concurrency against a shared resource
depends_on_past serializes runs; use deliberately, it stalls on one failure
max_active_tasks per-DAG task concurrency
@task(execution_timeout=timedelta(hours=2), pool="warehouse", pool_slots=2)
def heavy(): ...

D.4 Sensors, and the Deadlock

A sensor waits for a condition and holds a worker slot while it does.

# wrong: 24 sensors x 6 hours will exhaust the pool and deadlock the DAG
wait = S3KeySensor(task_id="wait", bucket_key="s3://bronze/orders/_SUCCESS",
                   poke_interval=60, timeout=60 * 60 * 6)

# right: releases the slot while waiting
wait = S3KeySensor(task_id="wait", bucket_key="s3://bronze/orders/_SUCCESS",
                   poke_interval=60, timeout=60 * 60 * 6,
                   deferrable=True)                 # or mode="reschedule"

deferrable=True uses the triggerer process and is the modern answer. mode="reschedule" releases the slot between pokes and is available everywhere.

Always set a timeout. A sensor without one waits forever, and "forever" includes the day the upstream is decommissioned.


D.5 Datasets: Scheduling on Data

ORDERS = Dataset("s3://bronze/orders")

@dag(schedule="0 2 * * *")
def producer():
    @task(outlets=[ORDERS])
    def load(): ...
    load()

@dag(schedule=[ORDERS])                 # runs when ORDERS is updated
def consumer():
    @task
    def transform(): ...
    transform()

Two DAGs, no cron guessing, no ExternalTaskSensor. The consumer runs when the producer says the data is there.

Multiple datasets are ANDed: schedule=[ORDERS, CUSTOMERS] waits for both.


D.6 Dynamic Task Mapping

@task
def list_partitions(data_interval_start=None):
    return [f"{data_interval_start.date()}/{h:02d}" for h in range(24)]

@task(max_active_tis_per_dag=4)
def load_partition(part: str):
    ...

load_partition.expand(part=list_partitions())

Useful, and it makes the DAG's shape depend on data, which complicates monitoring — a run with zero mapped tasks succeeds silently.

Assert the count:

@task
def list_partitions(...):
    parts = [...]
    if not parts:
        raise ValueError("no partitions to load -- upstream is empty")
    return parts

D.7 Trigger Rules

Rule Runs when
all_success default; every upstream succeeded
all_done every upstream finished, any state — use for cleanup
none_failed no upstream failed; skipped is fine
none_failed_min_one_success the one you want after a branch
one_failed for an alerting task
always unconditional

The common mistake: a task after a BranchPythonOperator with the default all_success is skipped, because one branch was skipped. Use none_failed_min_one_success.


D.8 XCom: What It Is Not

XCom passes small values between tasks through the metadata database.

@task
def a(): return {"rows": 4193, "path": "s3://bronze/orders/2026-11-14/"}

@task
def b(info: dict): print(info["rows"])

b(a())

It is not a data transport. Passing a DataFrame through XCom writes it into the Airflow database, which is a way to make the scheduler slow for everybody. Pass a path; read the data in the task.


D.9 Idempotency Patterns

# 1. delete-and-insert by partition -- the simplest correct pattern
@task
def load(data_interval_start=None):
    part = data_interval_start.strftime("%Y-%m-%d")
    con.execute("DELETE FROM bronze.orders WHERE event_date = ?", [part])
    con.execute("INSERT INTO bronze.orders SELECT * FROM read_parquet(?)",
                [f"s3://bronze/orders/event_date={part}/*.parquet"])

# 2. write to a temp location, then atomically swap
@task
def load_atomic(data_interval_start=None):
    tmp = f"s3://scratch/{ti.run_id}/"
    write(tmp)
    promote(tmp, final)          # a single metadata commit in a table format

# 3. a run-scoped marker, so a partial rerun is detectable
@task
def finalize(data_interval_start=None):
    write_marker(f"_SUCCESS_{data_interval_start.date()}")

Pattern 1 covers most cases and requires the partition to align with the interval (ch20).


D.10 Operational Commands

airflow dags list
airflow dags list-import-errors            # the one the UI hides
airflow dags test kestrel_daily 2026-11-14 # run it locally, no scheduler
airflow tasks test kestrel_daily load 2026-11-14

airflow dags backfill kestrel_daily \
  --start-date 2026-11-01 --end-date 2026-11-14 --reset-dagruns

airflow tasks clear kestrel_daily --start-date 2026-11-14 --end-date 2026-11-14 --downstream

airflow pools list
airflow pools set warehouse 8 "warehouse concurrency"

airflow db check
airflow db clean --clean-before-timestamp 2026-01-01   # the metadata DB grows

airflow dags list-import-errors is the first thing to run when a DAG does not appear. The UI shows nothing; this shows the exception.


D.11 🧭 Airflow 3

Airflow 3 renames and restructures several things this appendix teaches. The concepts survive; the names do not.

2.x 3.x
Dataset Asset
schedule=[Dataset(...)] schedule=[Asset(...)]
execution_date (already deprecated) gone; use logical_date
direct metadata-DB access from a task removed; tasks use an API server
SubDagOperator gone; use TaskGroups

The task-to-database change is the consequential one: in 3.x a task cannot query the Airflow metadata database directly, which breaks a common (and always ill-advised) pattern of reading task state from inside a task.

What does not change: the logical date, the data interval, idempotency, sensors holding slots, trigger rules, and the reason catchup=True surprised you.


D.12 When Not to Use Airflow

Airflow is a scheduler with dependencies. It is not:

A data transport. Do not move data through it; move it between systems and have Airflow say when.

A streaming system. Its minimum granularity is a schedule, and Chapter 29's four questions usually say batch anyway.

A necessity. For fewer than about ten jobs with simple dependencies, cron plus a lock file plus a Slack webhook is genuinely sufficient and has no operational surface. Adopt an orchestrator when you have dependencies, backfills, and retries to reason about — which is Chapter 24's threshold, not a vendor's.