S050 · Orchestration — Airflow, DAGs, Retries, Backfills
Every pipeline eventually becomes an ‘orchestration problem’. Learn Airflow's mental model — DAG, task, operator, XCom — and the four ideas that separate a stable warehouse from a 3am pager: retries, idempotence, backfills, SLAs.
🎯 Design an Airflow DAG that handles retries, backfills, and dependency ordering correctly, and defend Airflow (or its modern replacements) as the right tool for a batch-heavy stack.
Why this session exists
Every serious data platform has an orchestrator. Yours will probably be Airflow (still the most popular), or Prefect / Dagster / Argo (the modern alternatives). All of them exist to solve the same four problems: ordering (task B waits for task A), retries (transient failures shouldn't page you), backfills (re-run a date range without editing code), and scheduling (run at 3am, on cron, on trigger). Get those four right and your pipelines run themselves. Get them wrong and you have a full-time on-call rotation.
- Explain a DAG, task, operator, and XCom to a junior in one sentence each.
- Write an idempotent task and know why non-idempotent tasks are the #1 orchestration bug.
- Backfill a DAG from a past date and reason about what re-runs.
- Configure retries, retry_delay, and SLA the correct way.
- Choose between Airflow, Prefect, Dagster, and Argo with three concrete criteria.
Prerequisites
- S046 — Batch vs streaming (orchestrators live in the batch world).
- S047 — Spark (Airflow's job is to run your Spark jobs on schedule).
(a) Intuition · 5 min
A wedding has 40 things that must happen in order: florist arrives before ceremony, ceremony before reception, cake cut before speeches. Each has dependencies, expected duration, a fallback if something fails, and a person to call. A wedding coordinator holds the printed schedule (DAG), makes sure task N waits for task N-1, retries when the flower delivery is late (retries + delay), and can re-run the ‘set up ceremony space’ task if it went wrong before guests arrive (backfill).
Airflow is that coordinator for data pipelines. A DAG (Directed Acyclic Graph) is the schedule; a task is one job (e.g. ‘run this SQL’); an operator is the class that knows how to do it (PostgresOperator, SparkSubmitOperator, PythonOperator). Between tasks flows small metadata via XCom; large data flows through storage (S3, HDFS, warehouse tables) — never through the orchestrator itself.
The four ideas that unlock everything
- DAG = a Python file defining tasks + their dependencies. Loaded fresh every scheduler tick.
- Task = one unit of work. Runs on a worker; may retry; produces XCom.
- Operator = the class implementing HOW a task runs (bash, python, SQL, Spark, K8s pod).
- Idempotence = re-running the same task for the same date produces the same result. THE most important property; violate it and backfills lie.
A quick history so you know why the world looks like this
- 2008Oozie at Yahoo!First open-source Hadoop orchestrator. XML DAGs. Painful.
- 2015Airflow at AirbnbMaxime Beauchemin builds Python DAGs + web UI. Open-sourced.
- 2019Airflow 2.0 announcementsTask-flow API, scheduler HA, TaskGroups, smart sensors.
- 2020Prefect + Dagster shipModern alternatives targeting Airflow's pain points (Python-first, better local dev, asset lineage).
- 2022Argo Workflows for K8s-nativeYAML DAGs, container-per-task, K8s-native scheduling. Popular in ML platforms.
- 2024Airflow 3 · async, dynamic task mappingModern async operators, cleaner API. Still the industry default.
(b) Visual walkthrough · 15 min
Anatomy of an Airflow deployment
A tiny DAG walkthrough
The four ‘solved problems’ Airflow gives you
Transient failures don't page you.
- retries=3 + retry_delay=timedelta(minutes=5)
- Exponential backoff via retry_exponential_backoff=True
- Different values per task
- retry_callback for custom logic
Re-run any historical date range.
- airflow dags backfill -s 2024-01-01 -e 2024-01-31
- Requires idempotent tasks
- Marks past runs as ‘success’ or ‘failed’ in metadata
- catchup=False skips old runs on DAG creation
‘This should finish by 6am’.
- sla=timedelta(hours=2)
- SLA miss triggers email/webhook
- Independent of task failure
- Metric-worthy in Prometheus
‘Wait for X to exist before running’.
- ExternalTaskSensor (wait for another DAG's task)
- S3KeySensor / GCSObjectSensor
- poke_interval + timeout
- Prefer ‘deferrable’ sensors in Airflow 2.2+ (async, no worker slot held)
Idempotence — the one property that makes the rest work
What idempotence really means for a task
"Airflow is a scheduler. It's cron with a nicer UI and dependency arrows."
Airflow is a workflow orchestrator: its job is dependency resolution, retries, backfills, SLAs and observability. The scheduling is the least interesting part. And critically, it is an orchestrator, not a compute engine — a task should tell some other system to do work, not do the work inside the Airflow worker.
Because the entry point is a schedule interval and a DAG of tasks, which is exactly what cron plus a shell script feels like, and small pipelines really can be written that way. It breaks in two directions at once: a task that does heavy pandas work inside the worker will fight every other task for that machine's memory, and a pipeline written without idempotency cannot be safely retried or backfilled — which is the entire reason you adopted an orchestrator.
The two habits that separate an orchestrator from cron — idempotency and pushing compute out:
# ANTI-PATTERN: compute in the worker, and non-idempotent append
def bad(**ctx):
df = pd.read_sql('SELECT * FROM huge_table', conn) # OOMs the worker
df.to_sql('results', conn, if_exists='append') # rerun -> duplicates
# BETTER: submit to a compute engine, write idempotently for the data interval
def good(**ctx):
ds = ctx['data_interval_start'].strftime('%Y-%m-%d')
submit_spark_job(script='transform.py', args=['--date', ds])
# transform.py does: DELETE partition ds, then INSERT partition ds
# (or MERGE on a key) - so a rerun produces the identical result
# The test for every task you write:
# if this runs twice for the same data interval, is the end state identical?
# If no, backfill and retry are both unsafe, and you have cron.Why must every Airflow task be idempotent? This is stated as a best practice, but it is actually a hard requirement that follows from what an orchestrator is.
- 1An orchestrator runs tasks on distributed workers across an unreliable network, so it can never be certain whether a task that stopped reporting actually completed.forced by · a worker can finish its work and then die before recording success — success and crash-after-success are indistinguishable to the scheduler
- 2Faced with that ambiguity, the orchestrator has two options: retry (risking a duplicate execution) or give up (risking a silently missing run).forced by · there is no third option without a reliable failure detector, which does not exist in an asynchronous system
- 3Giving up means a human must intervene on every transient network blip, which defeats the purpose of automation entirely.forced by · transient failures are common at scale; a system that cannot self-heal from them is not operable
- 4So the orchestrator must retry, which means at-least-once execution is the guarantee — the same task will run more than once.forced by · exactly-once execution requires distributed consensus on completion, which is expensive and still cannot cover external side effects
- 5Therefore correctness cannot come from "it only ran once". It must come from the task producing the same end state regardless of how many times it ran.forced by · if the guarantee is at-least-once, the only way to be correct is to make repetition harmless
Therefore idempotency is not a style preference — it is the property that makes at-least-once execution safe, and without it retries actively corrupt your data.
And note what this predicts: the same requirement forces the design of backfills. A backfill is a deliberate re-execution of past intervals, so a pipeline that cannot be safely retried also cannot be safely backfilled — and backfilling is the single most common reason anyone adopts an orchestrator. It also predicts the standard pattern: partition output by the data interval and overwrite that partition, or MERGE on a key. Both make "run again" a no-op, which is precisely what the derivation requires.
Airflow is the conductor: it knows the score, knows who plays when, notices when someone misses an entry, and starts them again. It does not play an instrument. The actual work happens in Spark, in the warehouse, in Kubernetes — the orchestrator only decides what runs when and what to do when it fails.
A DAG is therefore a statement about dependencies and time, not about computation. Each task is a small, idempotent, retryable unit whose real job is to hand work to a system built for it and wait for the answer.
- Tasks are idempotent and keyed on the data interval, not on wall-clock "now". Using
datetime.now()inside a task makes backfills produce wrong results silently. - The DAG file is parsed constantly by the scheduler. Heavy imports or API calls at the top level slow down the whole system — keep the module import cheap.
- Push compute out. The worker should submit and poll, so that worker memory and CPU are never the bottleneck and one runaway task cannot starve the rest.
- Pass pointers, not payloads. XComs are for small metadata — a path, an ID, a count — while the data itself goes to storage.
Fire this model the moment you see: a cron job with a dependency on another cron job · a pipeline that needs to be re-run for a past date · retries producing duplicate rows · a task reading "today" instead of its data interval · pandas doing heavy lifting inside a worker · a chain of scripts coordinated by sleep statements · "did last night's job actually finish?" asked by a human.
Your pipeline is a long chain of transformations. Model it as many fine-grained tasks, or a few coarse ones?
Split tasks along failure boundaries, not along logical ones. The right question is not "is this a distinct step conceptually?" but "if this fails, what is the smallest amount of work I want to repeat?". That reframing usually produces a DAG with far fewer, better-chosen tasks than the one people write first.
The second boundary worth respecting is the system boundary: one task per external system call, so that a Spark job is one task and a warehouse MERGE is another. That keeps retries meaningful — retrying a task then means retrying one well-defined external operation, which is the only kind of retry you can reason about at 3am.
(c) Hands-on · 25 min
A minimal idempotent Airflow DAG using the modern TaskFlow API. Save as dags/orders_pipeline.py.
# dags/orders_pipeline.py — end-to-end idempotent daily pipeline.
# Requires: Airflow >= 2.7. Uses TaskFlow, sensors, retries, SLAs.
from __future__ import annotations
import datetime as dt
import logging
from airflow.decorators import dag, task
from airflow.sensors.external_task import ExternalTaskSensor
from airflow.exceptions import AirflowSkipException
from airflow.providers.postgres.hooks.postgres import PostgresHook
log = logging.getLogger(__name__)
DEFAULT_ARGS = {
"owner": "data-eng",
"retries": 3,
"retry_delay": dt.timedelta(minutes=5),
"retry_exponential_backoff": True,
"max_retry_delay": dt.timedelta(minutes=30),
"sla": dt.timedelta(hours=2),
"email_on_failure": True,
}
@dag(
dag_id="orders_pipeline",
default_args=DEFAULT_ARGS,
schedule="0 3 * * *", # 3am daily
start_date=dt.datetime(2024, 1, 1),
catchup=False, # don't backfill on new DAG
max_active_runs=1, # never run two copies of same DAG at once
tags=["orders", "daily", "warehouse"],
)
def orders_pipeline():
# --------------------------------------------------------------
# 0. Wait for upstream ingest DAG to finish for the same logical date
# --------------------------------------------------------------
wait_ingest = ExternalTaskSensor(
task_id="wait_ingest_orders",
external_dag_id="ingest_orders",
external_task_id="publish_to_lake",
allowed_states=["success"],
failed_states=["failed", "upstream_failed", "skipped"],
poke_interval=60,
timeout=60 * 60 * 4, # 4 h max wait
mode="reschedule", # release worker slot while waiting
)
# --------------------------------------------------------------
# 1. Idempotent stage → INSERT OVERWRITE partition by execution_date
# --------------------------------------------------------------
@task(task_id="stage_orders")
def stage_orders(logical_date: dt.datetime | None = None) -> int:
ds = logical_date.date()
hook = PostgresHook(postgres_conn_id="warehouse")
# Deleting first + inserting = idempotent on re-run
hook.run(f"""
BEGIN;
DELETE FROM stage.orders WHERE order_date = '{ds}';
INSERT INTO stage.orders (order_id, customer_id, amount, order_date)
SELECT order_id, customer_id, amount, order_date::date
FROM lake.raw_orders
WHERE order_date::date = '{ds}';
COMMIT;
""")
(count,) = hook.get_first(
"SELECT COUNT(*) FROM stage.orders WHERE order_date = %s", parameters=[ds]
)
log.info("staged %s rows for %s", count, ds)
return count
# --------------------------------------------------------------
# 2. Transform → MERGE into mart.fact_orders (upsert on natural key)
# --------------------------------------------------------------
@task(task_id="load_fact")
def load_fact(row_count: int, logical_date: dt.datetime | None = None) -> None:
if row_count == 0:
raise AirflowSkipException("no rows for this date")
ds = logical_date.date()
hook = PostgresHook(postgres_conn_id="warehouse")
hook.run(f"""
MERGE INTO mart.fact_orders t
USING stage.orders s ON t.order_id = s.order_id
WHEN MATCHED THEN UPDATE SET
customer_id = s.customer_id, amount = s.amount, order_date = s.order_date
WHEN NOT MATCHED THEN INSERT (order_id, customer_id, amount, order_date)
VALUES (s.order_id, s.customer_id, s.amount, s.order_date);
""")
# --------------------------------------------------------------
# 3. Data-quality gate (simple assertion; real teams use dbt tests / GE)
# --------------------------------------------------------------
@task(task_id="dq_check")
def dq_check(logical_date: dt.datetime | None = None) -> None:
ds = logical_date.date()
hook = PostgresHook(postgres_conn_id="warehouse")
(bad,) = hook.get_first(
"SELECT COUNT(*) FROM mart.fact_orders WHERE order_date = %s AND amount < 0",
parameters=[ds],
)
if bad > 0:
raise ValueError(f"{bad} negative-amount rows found for {ds}")
n = stage_orders()
fact = load_fact(n)
qc = dq_check()
wait_ingest >> n
fact >> qc
orders_pipeline()What each block does
Anatomy of the DAG
Edit the DAG: change the DQ check to also assert that today's row count is within 20 % of the 7-day rolling average. If not, raise ValueError.
Run the DAG on a day with unusually low volume. Watch:
dq_checkturns red.- Downstream tasks stay grey (
upstream_failed). - If configured, an email / Slack alert fires.
This is the ‘data-quality circuit breaker’ every mature warehouse has.
(d) Production reality · 15 min
Airbnb's early Airflow deployment had DAGs with top-level HTTP calls to their internal service registry. Every scheduler heartbeat (~30 s) hammered the registry with thousands of calls. Registry team escalated; scheduler CPU also blown.
Rewrote DAGs to move all side effects into task functions. Added a linter that fails PRs on top-level network calls. Airflow docs now warn about this as ‘the #1 mistake’.
A team's daily DAG had 60 tasks, none of them idempotent. When they needed to backfill 30 days of data after fixing a bug, half the tasks double-processed (INSERT without dedup), the other half failed on unique constraint violations. Backfill took 4 days of manual DBA work.
Rewrote every task with DELETE + INSERT partition-of-day OR MERGE upsert. Backfills went from ‘call the DBA’ to ‘click a button in the UI’.
A team enabled `catchup=True` (default in old Airflow) with a start_date 2 years in the past. Deployment triggered 730 concurrent DAG runs. Warehouse died under load; on-call spent a night killing runs from the CLI.
ALWAYS set `catchup=False` for new DAGs. Use `max_active_runs=1` to prevent overlap even during backfills. If you need historical runs, use the `backfill` CLI explicitly with a date range.
Where this shows up in the rest of the plan
(e) Recall + stretch · 10 min
Explain-out-loud test
- Airflow in 30 seconds — DAG, task, operator, scheduler.
- Why idempotence — one concrete pattern.
- The three deploy-time settings — catchup, max_active_runs, start_date.
What comes next
Hub: The 6-Month Learning Plan
Part of a 130-session evergreen learning series. Session structure: intuition → visual → hands-on → production war stories → recall. Duration: 90 minutes.