🔀 Pipelines & Orchestration · Chapter 4
Scheduling, dependencies and the backfill problem
The run stamped 14 March starts on 15 March. Until that sentence feels obvious, every Airflow schedule, cross-DAG sensor and backfill you write will be off by one.
Study this lesson free in the app →
The basics
Scheduling in Airflow means deciding which slice of time each run is responsible for, and when it is allowed to start. Dependencies decide what must finish first — inside a DAG and across DAGs. The backfill problem is re-running many past slices without corrupting data or crushing your systems.
The problem it solves: pipelines process periods — yesterday's rides, the last hour's payments. A run can only process a period after it has ended, and re-runs must hit exactly the same period. Get this wrong and you double-count, miss days, or wait forever for an upstream run that will never match.
The mental model: each run is a labelled envelope. The label says "rides for 14 March" (the data interval — 14 Mar 00:00 to 15 Mar 00:00). You can only fill the envelope once 14 March is over, so it is opened on 15 March. The label never changes, no matter when you re-open it.
Words you must know 5
- Data interval
[data_interval_start, data_interval_end)the run covers- Logical date (
ds) — the label; for normal schedules it equals the interval start - Catchup
- create runs for every missed interval since
start_date - ExternalTaskSensor
- wait for a task in another DAG
- Dataset
- data-aware trigger: run when an upstream task updates a dataset
Explained
Meghalaya Cabs' daily_revenue DAG runs at 00:00. The run that starts at 00:00 on 15 March has ds = 2026-03-14.
Not a bug. It's the most important convention in Airflow — and the source of most of its off-by-one incidents.
The newspaper's date
The newspaper delivered on the morning of the 15th reports what happened on the 14th. The editors couldn't print it on the 14th — the day wasn't over.
If the printing press breaks and the paper is reprinted on the 17th, it still covers the 14th. Nobody prints the 17th's news on the 14th's edition.
And the sports supplement can't go out until the main edition is printed — but if the main edition is printed at midnight and the supplement checks at 2 am for an edition labelled 2 am, it waits forever. Labels have to match, not clocks.
Intervals, labels and start times
schedule='@daily', start_date=2026-03-01 (Asia/Kolkata)
data interval ds / logical_date run starts at
─────────────────────────────────────────────────────────────
[03-01 00:00, 03-02 00:00) 2026-03-01 03-02 00:00
[03-02 00:00, 03-03 00:00) 2026-03-02 03-03 00:00
[03-03 00:00, 03-04 00:00) 2026-03-03 03-04 00:00
[03-04 00:00, 03-05 00:00) 2026-03-04 03-05 00:00
Deployed 03-05 10:00:
catchup=True → runs for 03-01, 03-02, 03-03, 03-04 queued now
catchup=False → only 03-04 (latest finished interval), then onwardThe first run happens one full interval after start_date. Your task should read the window from data_interval_start / data_interval_end, never compute it.
Precisely (Airflow 2.x)
- Data interval
[start, end)— what the run covers. Templates:{{ data_interval_start }},{{ data_interval_end }},{{ ds }},{{ ds_nodash }} - Logical date — the run's identity (formerly
execution_date, deprecated since 2.2). For cron/timedelta schedules it equalsdata_interval_start - Run after — a run is created when its interval ends
- Timezones — Airflow stores UTC. Give
start_datea timezone (pendulum.datetime(2026, 3, 1, tz='Asia/Kolkata')) or@dailymeans midnight UTC = 05:30 IST - Timetables — cron presets,
timedelta,CronTriggerTimetable(runs at the cron time; logical date = trigger time, no preceding interval), custom timetables (e.g. business days) - catchup —
Trueby default in Airflow 2 (configcatchup_by_default); Airflow 3 flips the default toFalse - depends_on_past=True — a task waits for its own previous run to succeed; makes backfills sequential and one failure blocks all later days
- max_active_runs — how many intervals run in parallel; your main backfill throttle
Three ways to depend on another DAG
Sensor = loose coupling but schedules must line up. Trigger = producer knows its consumers. Dataset = neither knows the other's schedule (renamed assets in Airflow 3).
Cross-DAG dependencies in code
# 1. Pull: wait for upstream task with the *same logical date*
wait = ExternalTaskSensor(
task_id='wait_rides_ingest',
external_dag_id='rides_ingest', external_task_id='publish_silver',
# upstream runs at 00:00, we run at 02:00 → shift to match ITS label
execution_delta=timedelta(hours=2),
mode='reschedule', timeout=4 * 3600)
# 2. Data-aware: producer declares what it updates
SILVER_RIDES = Dataset('s3://mc-lake/silver/rides')
@task(outlets=[SILVER_RIDES])
def publish_silver(): ...
# consumer DAG runs whenever that task succeeds
@dag(schedule=[SILVER_RIDES], start_date=pendulum.datetime(2026, 3, 1, tz='Asia/Kolkata'))
def daily_revenue(): ...execution_delta is subtracted from this run's logical date to find the upstream one. Forget it when schedules differ and the sensor waits for a run that doesn't exist.
The six-month backfill
Task: a bug in fare rounding affected fact_rides from 1 Sep to 28 Feb — 181 days. Downstream: daily_revenue, driver_payouts, and an ML feature table.
What a careful engineer does:
- Confirms every affected task is idempotent per interval (overwrite partition / MERGE)
- Runs
airflow dags backfill -s 2025-09-01 -e 2026-02-28 rides_modelwithmax_active_runsat 4 so the warehouse and replica survive - Checks for cumulative tables —
driver_lifetime_earningsbuilt as yesterday + today must be rebuilt in order, or rebuilt from scratch - Cascades downstream: clears dependent DAGs for the same range (or
ExternalTaskMarkerclears them with--include-downstream) - Verifies counts and ₹ totals per day before and after, against the payment gateway
At scale: the backfill problem
Throughput vs blast radius. 181 days × 30 tasks = 5,430 task instances. Unthrottled, they fill every worker slot and starve tonight's production runs. Use max_active_runs, a dedicated pool, or run backfills on a separate queue.
Order matters only for state. Partition-overwrite tasks can run in any order, in parallel. Anything cumulative (running balances, SCD2 dimensions, depends_on_past) must go sequentially — or be redesigned to recompute from raw.
Late-arriving data is a permanent mini-backfill. If 2% of rides arrive up to 3 days late, design each run to reprocess a trailing window (the last 3 intervals) — or use a lookback in incremental models.
Backfill the cheapest layer. If raw is correct, re-run model and serve only. Re-extracting six months from a partner API may be rate-limited or impossible.
Words to know
| Data interval | The time window [start, end) a DAG run is responsible for processing. |
|---|---|
| Logical date | The run's identity label; equals the data interval start for cron and timedelta schedules. |
| Catchup | Creating runs for all intervals between start_date (or the last run) and now. |
| ExternalTaskSensor | A sensor that waits for a task in another DAG with a matching logical date. |
| Dataset (data-aware scheduling) | A URI a task declares it updates; DAGs scheduled on it run when it's updated. |
| depends_on_past | A task setting requiring the same task's previous run to have succeeded. |
| max_active_runs | The maximum number of concurrent runs of one DAG; the main backfill throttle. |
In two minutes
- Every run covers a data interval [start, end); for normal schedules the logical date (ds) is the interval start.
- A run is created when its interval ends — the run labelled 14 March starts at 00:00 on 15 March.
- Tasks must read data_interval_start/end from the context so re-runs hit the same window.
- Set a timezone on start_date, otherwise @daily means midnight UTC (05:30 IST).
- catchup=True (Airflow 2 default) creates every missed interval; False creates only the latest finished one.
- Cross-DAG: ExternalTaskSensor (matching logical dates, execution_delta for offsets), TriggerDagRunOperator, or Datasets (data-aware).
- Backfills need idempotent tasks, throttling (max_active_runs, pools), sequential order for cumulative state, and downstream cascade.
Test yourself
1. A @daily DAG's run has ds = 2026-03-14. When does it normally start?
- 2026-03-14 00:00
- 2026-03-15 00:00, when the 14 March interval ends
- 2026-03-14 23:59
- Whenever a worker is free on the 14th
Show the answer
2026-03-15 00:00, when the 14 March interval ends — A run is created after its data interval closes. The label is the interval start; the start time is the interval end.
2. For a cron-scheduled DAG in Airflow 2, the logical date equals…
- data_interval_start
- data_interval_end
- The wall-clock time the task started
- The DAG's start_date always
Show the answer
data_interval_start — For interval-based timetables the logical date is the start of the data interval. (CronTriggerTimetable is the exception: logical date = trigger time.)
3. A DAG with start_date = 2026-03-01 (no catchup override, Airflow 2 defaults) is deployed on 2026-03-10. What happens?
- Only today's run is created
- Nothing until 11 March
- Runs for 1–9 March are queued, because catchup defaults to True
- Only the 1 March run
Show the answer
Runs for 1–9 March are queued, because catchup defaults to True — catchup_by_default is True in Airflow 2, so every missed interval is scheduled. Airflow 3 changed this default to False.
4. Same DAG, but catchup=False. Which run is created on deploy?
- None
- All nine
- Only 1 March
- Only the most recent completed interval (9 March)
Show the answer
Only the most recent completed interval (9 March) — With catchup disabled the scheduler creates the latest finished interval and continues from there.
5. rides_ingest runs @daily at 00:00. daily_revenue runs at 02:00 and uses ExternalTaskSensor on it with no other settings. What happens?
- It waits for an upstream run with logical date at 02:00 that never exists, until timeout
- It works — sensors match by date only
- It triggers rides_ingest
- It runs immediately
Show the answer
It waits for an upstream run with logical date at 02:00 that never exists, until timeout — The sensor looks for the same logical date. Upstream labels are at 00:00, so set execution_delta=timedelta(hours=2) (or execution_date_fn).
6. Meghalaya Cabs wants daily_revenue to run whenever silver.rides is published, regardless of the producer's schedule. Most direct Airflow 2.4+ feature?
- depends_on_past
- Datasets: outlets on the producer task, schedule=[Dataset] on the consumer
- A cron schedule 10 minutes later
- catchup=True
Show the answer
Datasets: outlets on the producer task, schedule=[Dataset] on the consumer — Data-aware scheduling decouples schedules: the consumer is triggered by the dataset update event.
There are 10 questions, 3 puzzles and 5 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →