Search Tech Journey

Find topics, journeys and posts

6-month learning plan50 / 130
back to blog
data engineeringintermediate 55m read

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.

🗄️Data EngineeringM05 · Data Engineering· Session 050 of 130 90 min

🎯 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.

You will be able to
  • 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 coordinator with a printed schedule
🌍 Real world

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).

💻 Code world

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

Airflow's mental model
  • 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

  1. 2008
    Oozie at Yahoo!
    First open-source Hadoop orchestrator. XML DAGs. Painful.
  2. 2015
    Airflow at Airbnb
    Maxime Beauchemin builds Python DAGs + web UI. Open-sourced.
  3. 2019
    Airflow 2.0 announcements
    Task-flow API, scheduler HA, TaskGroups, smart sensors.
  4. 2020
    Prefect + Dagster ship
    Modern alternatives targeting Airflow's pain points (Python-first, better local dev, asset lineage).
  5. 2022
    Argo Workflows for K8s-native
    YAML DAGs, container-per-task, K8s-native scheduling. Popular in ML platforms.
  6. 2024
    Airflow 3 · async, dynamic task mapping
    Modern 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

Retries

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
Backfills

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
SLAs

‘This should finish by 6am’.

  • sla=timedelta(hours=2)
  • SLA miss triggers email/webhook
  • Independent of task failure
  • Metric-worthy in Prometheus
Sensors

‘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

Same execution_date + same code = same output
Airflow passes the execution_date (logical date) as a template variable. Filter data by it, don't use now(). Now() is the fastest way to make a task non-idempotent.
critical
Output write is idempotent
INSERT OVERWRITE partition-of-day; DELETE+INSERT with a transaction; UPSERT with natural key. Never plain INSERT.
sink
External API calls use idempotency keys
Include execution_date in any external mutation ID; the API server dedups.
external
State lives in storage, not in the task
If the task crashes at step 4 of 5, restart at step 1 should complete step 5 correctly. This means step 5 doesn't depend on step 4's in-memory result — it re-reads it from storage.
resilience

Common misconception
✗ What most people think

"Airflow is a scheduler. It's cron with a nicer UI and dependency arrows."

✓ What is actually true

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.

Why the myth is so sticky

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.

Prove it to yourself

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.
From first principles
Start with the question

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.

  1. 1
    An 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
  2. 2
    Faced 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
  3. 3
    Giving 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
  4. 4
    So 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
  5. 5
    Therefore 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

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.

Mental modelConductor, not orchestra

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.
🔔 Fires when you see

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.

The tradeoff

Your pipeline is a long chain of transformations. Model it as many fine-grained tasks, or a few coarse ones?

Many fine-grained tasks
+ you gain failures are precisely located and only the failed step reruns, which on a long pipeline saves enormous amounts of recompute; independent branches run in parallel; the UI becomes real documentation of the flow
− you pay per-task scheduling overhead and latency add up, the DAG becomes visually unmanageable past a few dozen nodes, and intermediate results must be materialised somewhere to pass between tasks
pick when steps genuinely fail independently and re-running the whole thing is expensive — the usual case for multi-hour pipelines
Few coarse tasks
+ you gain less orchestration overhead, fewer intermediate materialisations, and the compute engine can optimise across the whole transformation rather than being forced to checkpoint at your task boundaries
− you pay a failure at 90% means redoing 100%, and observability collapses — the UI tells you "the transform failed" and nothing more
pick when the steps are tightly coupled, fast, and the engine optimises better when given the whole job — a single Spark application is usually one task, not ten
Coarse tasks with internal checkpointing
+ you gain combines engine-level optimisation with recovery granularity: the job restarts from its own last checkpoint rather than from the beginning, without the orchestrator needing to know the internal steps
− you pay you now own the checkpoint logic and its state, and the orchestrator's view of progress is coarse, so alerting and SLAs are less precise
pick when a single long-running job where restarting from scratch is unacceptable but splitting it would prevent whole-job optimisation
What a senior engineer actually does

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

default_args
Set retries + retry_delay + SLA at DAG level; override per task if needed. exponential_backoff avoids retry stampedes.
config
catchup=False + max_active_runs=1
Two non-negotiable settings for new DAGs. Otherwise the scheduler will trigger every missed run since start_date all at once when you deploy.
safety
ExternalTaskSensor with mode=reschedule
Waits for upstream DAG. mode=reschedule releases the worker slot between pokes — otherwise a long wait blocks a worker for hours.
sensor
DELETE + INSERT partition-of-day
The canonical idempotent pattern. Re-run for the same date = same result. NEVER use ‘INSERT if not exists’ patterns — they hide broken re-runs.
idempotence
MERGE with natural key
Upsert makes the fact-load idempotent too. Combined with stage's daily overwrite, the whole pipeline is safe to backfill.
idempotence
AirflowSkipException
Preferred over ‘return early’ — marks the task ‘skipped’ in the UI, doesn't count as failure.
control
wait_ingest &gt;\&gt; n / fact &gt;&gt; qc
TaskFlow supports both operator dependency (&gt;&gt;) and Python function chaining. Mix as needed.
topology
Try itAdd a data-quality gate that fails the DAG cleanly

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_check turns 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.

💡 Hint · Add a task that COUNT(*)s rows where amount < 0 and raises ValueError. Check the UI: dq_check turns red, downstream tasks skip (upstream_failed), Slack alert fires.

(d) Production reality · 15 min

War story Airbnb· 2017original Airflow author's post-mortem series
🔥 What broke

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.

🧯 The fix

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’.

🎓 Lesson to steal
The DAG file runs every heartbeat. Assume 3000 executions/day per file. Zero side effects at top level.
Post-mortem
War story Astronomer / customer sampledocumented in Airflow Summit talks
🔥 What broke

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.

🧯 The fix

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’.

🎓 Lesson to steal
Idempotence is not optional. If you can't re-run a task safely, you don't have an orchestrated pipeline — you have a manual runbook.
War story Common failure modeevery team, at least once
🔥 What broke

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.

🧯 The fix

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.

🎓 Lesson to steal
The three deploy-time settings that must be right: `catchup=False, max_active_runs=1, start_date < today`. Newer Airflow versions default `catchup=False`; still, always be explicit.

Where this shows up in the rest of the plan

Orchestration is the glue between data & production
S051 · dbt
Airflow runs dbt jobs on schedule; asset dependencies live in dbt.
S054 · Data quality
DQ tests as DAG tasks with circuit-breaker semantics.
S055 · CDC
CDC pipelines are usually streamed but reconciled by nightly DAGs.
S066 · Kubernetes
KubernetesExecutor / KubernetesPodOperator = task-per-pod isolation.
S073 · Observability
Alert on SLA misses, task retries, DAG duration drift.
S099 · MLOps
Model training + evaluation DAGs; Kubeflow is an ML-native Argo.

(e) Recall + stretch · 10 min

Recall — click each to reveal · click to reveal
★ = stretch question

Explain-out-loud test

  1. Airflow in 30 seconds — DAG, task, operator, scheduler.
  2. Why idempotence — one concrete pattern.
  3. 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.