Airflow Concepts
A DAG of tasks, a scheduler, a metadata database and workers — and the logical data interval, which is the most misunderstood idea in orchestration.
Who needs this, what one row is, and why the obvious build breaks
Every lesson starts from the consumer, because designing from the source outward is this domain's characteristic mistake.
When a run is labelled 2026-03-11 but executes on the 12th, which day's data is it responsible for — and what happens when you re-run it in June?
Anyone who will ever have to re-run a past day. The whole value of the interval concept is realised on the day someone says "the revenue logic was wrong all through March" and the answer is a bounded re-execution rather than a rewrite (Backfills).
The unit is the task instance: one task, one logical data interval, one attempt. The interval — not the execution timestamp — is the identity. Two task instances with the same interval are the same unit of work regardless of when either ran.
Treat the run as "the job that runs tonight" and have each task query WHERE created_at >= current_date - 1. It works perfectly every night that the job runs on schedule, which is most nights, and it is how a very large number of production pipelines are written.
The 11th's run fails and is restarted on the 13th. current_date - 1 now means the 12th, so the 11th is never processed and the 12th is processed twice. The re-run "succeeded" (Missing Rows, Duplicate Rows).
- The 11th's run fails and is restarted on the 13th.
current_date - 1now means the 12th, so the 11th is never processed and the 12th is processed twice. The re-run "succeeded" (Missing Rows, Duplicate Rows). - A backfill of March is launched in June. Every task computes its window from the June clock, so a hundred runs all reprocess the first week of June and overwrite each other (What Backfills Break).
- The pipeline moves from daily to hourly.
current_date - 1still means a day, so each of twenty-four runs rewrites the whole previous day, and the last one to finish wins. - A run is retried at 00:59 and again at 01:01 across a date boundary or a daylight-saving change, and the two attempts of the same task instance process different data (Processing Time).
- Someone reasonably asks "which run produced this row?" and there is no answer, because the row carries no interval and the run carried no identity beyond a timestamp (Data Lineage).
What is actually happening
- A DAG is a Python file that *declares* a graph: tasks and the edges between them. The file is parsed repeatedly by the scheduler, which means it is executed as code on a schedule — top-level work in a DAG file runs constantly, which is a common and expensive surprise.
- The scheduler examines the graph and the metadata database, creates DAG runs for intervals that are due, and marks task instances as ready when their dependencies are satisfied. The metadata database holds every task instance state and is the durable memory that makes any of this more than cron (Scheduler vs Orchestrator).
- An executor hands ready task instances to workers — separate processes, often on separate machines. Task code therefore does not run in the scheduler, does not share memory with it, and cannot pass Python objects to the next task except through an explicit small-value mechanism or through storage (Worker Processes).
- Every DAG run is bound to a logical date and data interval: the window of data the run is responsible for. A daily schedule produces intervals of one day, and the run for an interval is scheduled *after that interval closes* — the run labelled the 11th executes at the start of the 12th, because only then is the 11th complete. Those bounds are exposed to task code as template values, and a task written against them is a function of its interval rather than of the clock, which is exactly the property that makes re-running and backfilling meaningful (Idempotent Data Pipelines).
- Retries are configured on the task, not written into it: an attempt count, a delay, and optionally a per-task timeout. The retry re-runs the same task instance — same interval, new attempt — which is safe if and only if the task is idempotent (Retries in Pipelines).
- Catchup decides what happens to intervals between the DAG's start date and now. Left on, deploying a DAG with an old start date schedules every intervening interval, which is either exactly what you wanted or a self-inflicted load test (Retry Storms: The Load You Generated Yourself).
The moving parts
Airflow is often introduced as "a Python DAG file", which hides the fact that four separate long-running components have to agree with each other for anything to happen. Knowing which component does what is most of the diagnostic skill: a task that never starts, a task that starts late and a task that fails are three different components' problems.
The DAG file is code that the scheduler executes repeatedly in order to discover the graph. That is worth stating plainly because it is the source of a whole class of performance problems — a database query or an API call at the top level of a DAG file runs on every parse, not on every run.
The metadata database is the component that matters most and is discussed least. It holds every task instance state; it is what makes dependency evaluation, retries, backfills and coverage queries possible; and if you lose it, the platform forgets that any interval was ever processed.
deployment/
dags/ parsed by the scheduler on a loop — keep cheap
orders_daily.py declares tasks and edges only
plugins/
requirements.txt must exist on every worker, not just the scheduler
runtime:
scheduler ──▶ metadata DB ──▶ executor ──▶ workers ──▶ storage
▲ │
└──────────── outcomes ────────────────┘Component names, executor types and the exact module paths for operators have changed across Airflow major versions, and the field once known as the execution date is now expressed as the logical date with explicit data interval bounds. Verify the current documentation for names; the split between scheduler, metadata database, executor and workers has been stable for years.
The data interval is not the run time
This is the concept that repays the most attention. A daily DAG run labelled 2026-03-11 is responsible for the data of the 11th, and it executes shortly after the 11th ends — because that is the first moment the 11th is complete. The label is about the data; the execution timestamp is about the machine.
Everything that makes an orchestrator worth having depends on that separation. Re-running the 11th in June must process the 11th, and it does, because the task was written as a function of its interval bounds rather than of the clock. A task that filters on current_date - 1 breaks that property completely and the breakage is invisible until the first re-run.
The timeline below shows the second-order issue, which is that an interval closing is not the same as the interval's data having arrived. An order placed at 23:58 whose transaction commits at 00:04 belongs to the 11th by event time and is present only after the 12th has started. Whether the run sees it depends on whether the extract filters by event time or by arrival, and on how long the run waits (Late-Arriving Data).
| Event | Happened | Arrived | Lands in |
|---|---|---|---|
| A | 2026-03-11 09:14 | 2026-03-11 09:14 | interval 2026-03-11 The normal case: event time and arrival agree and both fall inside the interval. |
| B | 2026-03-11 23:58 | 2026-03-12 00:04 | interval 2026-03-11 Belongs to the 11th by event time. Present in the source only after the interval closed — the run must filter by event time and must start late enough to see it. |
| C | 2026-03-11 23:59 | 2026-03-12 00:11 | interval 2026-03-11, but missed The run for the 11th started at 00:05 and read the source before this row committed. It is correctly attributed and permanently absent until a re-run. |
| D | 2026-03-12 00:02 | 2026-03-12 00:02 | interval 2026-03-12 A task filtering on the wall clock rather than the interval would put this in the 11th, because to a 00:05 run "yesterday" is ambiguous for exactly five minutes. |
Times are clock labels for teaching, not measurements. The point of row C is that a correct interval binding does not by itself give completeness; it gives a well-defined thing to re-run once the late rows land (The High-Water Mark).
1-- Templated by the orchestrator: both bounds come from the run's2-- data interval, never from the clock on the worker.3-- {{ data_interval_start }} = 2026-03-11 00:00:00+004-- {{ data_interval_end }} = 2026-03-12 00:00:00+005 6-- WRONG: the window is whatever "today" happens to be on the worker.7-- A re-run in June processes June.8INSERT INTO fct_orders9SELECT * FROM stg_orders10WHERE order_ts >= current_date - INTERVAL '1 day'11 AND order_ts < current_date;12 13-- RIGHT: the window is the run's identity, and the write replaces14-- exactly that window rather than appending to it.15DELETE FROM fct_orders16WHERE order_ts >= TIMESTAMP '{{ data_interval_start }}'17 AND order_ts < TIMESTAMP '{{ data_interval_end }}';18 19INSERT INTO fct_orders20SELECT21 order_id,22 order_ts,23 customer_id,24 amount_minor,25 TIMESTAMP '{{ data_interval_start }}' AS interval_start,26 '{{ params.code_version }}' AS produced_by27FROM stg_orders28WHERE order_ts >= TIMESTAMP '{{ data_interval_start }}'29 AND order_ts < TIMESTAMP '{{ data_interval_end }}';Three things to notice beyond the templating. The bounds are half-open, so an event at exactly midnight belongs to one interval and not both. The delete-then-insert pair must be one transaction or one partition-level overwrite, or a failure between them leaves the interval empty. And stamping the code version onto the row is what later lets you find every row produced by the logic you have since fixed (Reprocessing vs Retrying).
Catchup, concurrency and the settings that decide whether history is safe
A small number of DAG-level settings decide what happens to intervals that have not been processed, and they are usually left at their defaults by people who have not yet had the corresponding incident. They are worth an explicit decision at creation time, with the reasoning written next to them.
The question underneath all of them is whether missing an interval is a gap to be filled or a moment that has passed. A financial ledger has gaps that must be filled; a dashboard showing current inventory has no use for the state of Tuesday. Both are legitimate, and the settings that suit one are dangerous for the other.
Whichever you choose, choose the concurrency limit at the same time. Catchup and backfill are the only situations in which a data pipeline suddenly attempts a hundred simultaneous runs against a source that has been serving one per day, and the source is rarely consulted about it (Unbounded Concurrency).
Is an unprocessed interval a gap that must be filled, or a moment that has passed?
when Every interval must exist: ledgers, event facts, anything a finance or audit consumer reads by date.
cost Deploying with an old start date schedules the entire history at once. Requires an active-run limit and a source that can take the load, and requires every task to be genuinely interval-bound.
when Only the current state matters — a dimension snapshot, an inventory refresh, a cache rebuild. Missing Tuesday is not a defect.
cost Gaps are invisible by design. Nothing will ever tell you the pipeline was down for three days, so you need dataset freshness monitoring instead (Freshness Checks).
when The common middle: the schedule serves the present, and history is repaired deliberately when a bug is found.
cost Recovery is a human decision, so it depends on someone noticing. Pairs badly with a team that has no coverage query (Planning a Backfill).
when The task mutates shared state that cannot tolerate two writers, or the source enforces a strict connection budget.
cost A backfill becomes strictly sequential, so repairing a long range takes as long as the range times the run duration. Predictable and slow.
when The source publishes its own completion signal and you would rather wait for data than for a clock (Ingestion Failure & Recovery).
cost Occupies a worker slot while waiting, and turns a fixed finish time into a variable one that downstream consumers will notice before you do.
How to build it
Most important first.
- Pass the interval into every task and never call
now()inside one. This single rule is what separates a pipeline that can be repaired from one that cannot, and it costs one templated parameter. - Write the output to a location keyed by the interval — a partition, a directory, a merge predicate — so a re-run of an interval replaces exactly that interval and touches nothing else (Partitioning).
- Keep DAG files cheap to parse. No database calls, no API calls and no heavy imports at the top level, because the scheduler evaluates that file far more often than the DAG runs.
- Set concurrency limits deliberately: how many runs of this DAG may be active, and how many instances of this task. The defaults are usually permissive, and a backfill is where that becomes visible (Unbounded Concurrency).
- Decide catchup explicitly at creation time rather than discovering the default. Pipelines that must process every interval want it on; pipelines that only care about the present want it off and a documented reason.
- Move data between tasks through storage, not through the orchestrator. The small-value passing mechanism exists for identifiers and counts, and using it for datasets puts your data in the metadata database (The Raw Landing Zone).
What this actually promises
Naming the guarantee you do not have is worth more than naming the one you do — everything downstream inherits the weakest promise in the chain.
- A task instance is executed at least once per attempt boundary: a worker can complete the work and fail to report it, and the scheduler will re-run it. Once-only *effects* are the task's responsibility, never the scheduler's (At-Least-Once Delivery).
- Dependencies are honoured as declared within a DAG for the same interval. Dependencies across DAGs, or across intervals of the same DAG, hold only if you declare them explicitly.
- The interval binding guarantees that the same run identity always refers to the same window of time. It does not guarantee that the *data* for that window was complete when the run executed — that depends on the source (Late-Arriving Data).
- A
successstate guarantees the task process exited without raising. Nothing more, and this is where most of the domain's incidents live (The Pipeline Succeeded. The Data Is Wrong.). - Task instances of the same task for different intervals may run concurrently unless limited. Ordering between intervals is not implied by the schedule.
Can I trust it?
A green pipeline is evidence that code ran. These four fields are the evidence that the data is right.
- The check this concept enables is interval coverage: query the metadata database for the set of intervals in a range and assert that every one has a terminal success state, then assert the corresponding output partitions exist with plausible row counts.
- It misses intervals that ran successfully over incomplete source data — coverage is about runs, not about rows — and it misses any dataset written outside the interval scheme (Reconciliation).
- It also cannot detect a task that was re-run with different code. Interval coverage says the window was processed; it does not say which version of the logic processed it (Reprocessing vs Retrying).
- Because the run for an interval starts after that interval closes, a daily DAG can never produce a result about today. The freshness floor of a schedule is one full interval plus the graph's critical path, by construction rather than by inefficiency.
- Shortening the interval shortens the floor and multiplies the number of runs, the number of output files and the per-run overhead. Hourly partitions of a small dataset are a small-file problem waiting to happen (File Size and the Small-Files Problem).
- Sensors that wait for source data convert a fixed schedule into a variable finish time. That is usually better data and worse predictability, and consumers notice the second one first (Pipeline SLOs).
- Changing the schedule changes the interval, which changes the meaning of every historical run label. A DAG that was daily until March and hourly after has two eras, and any query over its output partitions has to know that.
- Renaming a task orphans its history: the metadata database keys state by task id, so the new name has never run and the old name never will again. Coverage checks over the rename boundary need care.
- Airflow's own naming of these concepts has changed across major versions — the field once called the execution date is now expressed as the logical date and the data interval bounds. The idea is stable; the identifiers are not (Semantic Changes).
- Recovery is: clear the task instances for an explicit interval range, then let the scheduler re-create them. This works precisely because a task instance is identified by its interval, so re-running the 11th in June still processes the 11th.
- Before clearing anything, check whether clearing a task also clears its downstream. Recovering a task without recovering what consumed its output leaves a downstream table built from data that no longer exists (Partial Failure).
- Bound the concurrency of the recovery. Clearing three months of a daily DAG creates ninety runs that the scheduler will happily start at once against a production source; set an active-run limit first (Planning a Backfill).
- Prefer re-running into a shadow output and comparing before publishing, especially where the fix changes logic rather than repairing a missed run (Validating a Backfill Before You Publish).
- If a task cannot be safely cleared — because it appends, or because it calls an external system with side effects — that is not an Airflow problem to solve with settings. It is the task that must change (Idempotent Data Pipelines).
What can go wrong
- A task computing its own window from the wall clock, which makes every re-run and every backfill wrong in a way that produces no error.
- Top-level code in a DAG file querying a database on every parse, so the scheduler generates continuous load nobody attributed to it.
- Catchup enabled with an old start date, launching a year of runs on deploy.
- A sensor waiting indefinitely for data that will never arrive, occupying a worker slot and starving unrelated tasks (The Backlog Arithmetic: Four Levers and a Drain Time).
- Large payloads pushed through the inter-task value mechanism, bloating the metadata database until the scheduler itself slows.
- The mitigation failing: retries configured on a non-idempotent task, so the reliability feature is the thing that duplicates the data (Job Idempotency).
- "The run date is the date it ran." It is the interval it is responsible for. The run executes after that interval ends, and this off-by-one is the most common confusion in the whole tool.
- "A backfill is just re-running the DAG." Only if every task is a function of its interval. Otherwise a backfill is a hundred concurrent runs all processing today (What Backfills Break).
- "Airflow moves data between tasks." Workers are separate processes; anything larger than an identifier belongs in storage, and the inter-task mechanism is not a data channel.
- "Retries make the pipeline reliable." Retries make transient failures survivable. On a non-idempotent task they convert a transient failure into a permanent data error (Retries).
- "If the scheduler is healthy, the pipelines are running." A paused DAG is healthy and produces nothing. Absence is the failure mode neither the scheduler nor its alerts see (Freshness Monitoring).
Operating it
- Interval coverage per DAG — which intervals have a terminal success — is the single most useful query against the metadata database (Pipeline Observability).
- Scheduler loop duration and DAG file parse time. Both degrade gradually and both present as "everything is late" (Pipeline Metrics).
- Task instance attempt counts. A task that habitually succeeds on attempt two is failing daily and reporting success.
- Worker slot utilisation against queued task instances, which separates a slow pipeline from a starved one (Depth Is Not an Emergency; Age Is).
- At 10x task count, DAG parsing and the metadata database dominate scheduler behaviour long before worker capacity does.
- At 100x, run history becomes a table with a retention problem, and teams split one deployment into several rather than scaling one scheduler (Data Platform Engineering).
- Interval granularity scales worse than data volume: moving from daily to five-minute intervals multiplies runs, files and metadata rows by nearly three hundred without changing the amount of data (File Compaction).
- The scheduler and metadata database are a persistent baseline cost that does not vary with data volume. It varies with task count, parse frequency and run history retention.
- The dominant cost caused by these settings is reprocessing: catchup and unbounded backfills multiply downstream scan and write volume by the number of intervals (Compute Waste).
- Sensors that poll cost a worker slot for their whole wait. Deferring or event-based triggering trades that occupancy for more machinery (Cost vs Freshness).
- Interval binding buys reproducibility and costs discipline: every task must accept a range, every output must be keyed by it, and every developer must resist
now(). - A general-purpose orchestrator buys the ability to run anything and costs the guarantee that it knows what any of it did. Task-centric tools schedule work; they do not model datasets, so lineage and staleness must be added on top (Data Lineage).
- Retries buy resilience against transient faults and cost you a duplication risk on every task that is not idempotent — the feature and the hazard are the same mechanism.
Where this applies
Almost nothing here is universal. These labels say what each claim is specific to, and where a different engine, format, warehouse or scale would differ.
- TOOL-SPECIFICAirflow is used here as the worked example because its component split is explicit. Dagster models the same scheduling problem around declared assets, so its unit is a materialisation of a dataset rather than a task run; Prefect makes flows ordinary Python functions and derives the graph at runtime. The data-interval idea appears in all three under different names, and the component names below are Airflow's alone.
- GENERALThe separation of a logical data interval from wall-clock execution time is not Airflow-specific: any system that can re-run past work needs it, and any system that lacks it can only ever process the present. That part transfers to every orchestrator and to hand-written schedulers.
- SIMPLIFIEDThe component picture below omits the web server, the triggerer and the details of specific executors, which vary by deployment and by version. Those change what runs where; they do not change the scheduling logic being taught.
Where the depth lives
This domain teaches how data moves and how you know it arrived intact. It hands the rest off by name.
- — DevOps / Production Engineering owns the deployment of the DAG files themselves — the fact that a DAG is code that must be versioned, reviewed, tested and rolled back like any other service, and that "the worker did not have the library" is a packaging problem rather than a data one.
- — Distributed Systems owns why the gap between a worker finishing its work and recording that fact cannot be closed, which is the reason task execution is at-least-once and the reason interval-keyed overwrites matter more than retry settings.