Airflow DAG
What It Means, How It Runs, Examples (Airflow 3)
An Airflow DAG (directed acyclic graph) is a workflow defined in Python: a set of tasks and the one-way dependencies between them, which Airflow runs on a schedule. Directed means every dependency points 1 way; acyclic means no chain of dependencies ever loops back to where it started. This guide shows 2 example DAGs, how Airflow turns a DAG into runs, and what Airflow 3 changed about schedules, including logical_date and catchup.
What is a DAG in Airflow?
A DAG in Airflow is a Python file that defines a workflow as a directed acyclic graph: tasks are the nodes and the dependencies between them are the edges. Directed means every edge points one way, so extract runs before validate. Acyclic means no chain of edges ever leads back to a task it started from, so a task can never wait on itself. The DAG does no work of its own; it declares which tasks exist and the order they may run in, and the operators inside the tasks do the work.
The graph's shape decides what runs in parallel and what waits. A task with 2 downstream tasks fans out, and those branches run in parallel. A task with 2 upstream tasks fans in and waits for both. Branches can have different lengths and still rejoin, and 2 siblings can react differently to the same failure: load keeps the default rule of running only when everything upstream succeeded, while a cleanup task set to trigger_rule="all_done" runs whatever happened.
An edge from load back to extract would close a cycle, and Airflow refuses to load a DAG that contains one, because no task in the loop could ever start first. The Airflow documentation on DAGs now writes the word as Dag, since the object holds more than the graph: the schedule and callbacks live on it too, along with run settings. Older code and most interviewers still say DAG, and it is still what people search for. Both mean the same thing.
Airflow DAG example: a complete daily pipeline
import pendulum
from datetime import timedelta
from airflow.sdk import CronDataIntervalTimetable, dag, task
@dag(
schedule=CronDataIntervalTimetable("@daily", timezone="UTC"),
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
default_args={"retries": 2, "retry_delay": timedelta(minutes=5)},
tags=["signups"],
)
def signups_daily():
@task
def extract(data_interval_start=None) -> str:
"""Pull 1 day of signups from the API into object storage."""
day = data_interval_start.strftime("%Y-%m-%d")
path = f"s3://lake/raw/signups/{day}.parquet"
# fetch and write happen here; only the path crosses to transform
return path
@task
def transform(raw_path: str) -> str:
"""Dedupe on signup_id, normalise timestamps, overwrite the staged file."""
return raw_path.replace("/raw/", "/staged/")
@task
def load(staged_path: str) -> None:
"""MERGE into warehouse.signups keyed on signup_id, so a rerun is safe."""
load(transform(extract()))
signups_daily()A daily ETL pipeline written with the TaskFlow API for Airflow 3. Each function call creates a dependency, only file paths travel between tasks, and the MERGE makes a retried load safe.
What the signups_daily DAG does, line by line
The @dag decorator turns the function signups_daily into a DAG whose id is the function's name. Calling the function at the bottom of the file creates the DAG object at module level, which is the only place Airflow looks for one. The schedule argument uses CronDataIntervalTimetable on purpose: on Airflow 3 a bare "@daily" gives each run an interval of 0 length, and this DAG needs every run to cover exactly 1 finished day.
default_args apply to every task: 2 retries with 5 minutes between them, so a failing task gets 3 attempts and the last one starts at least 10 minutes after the first failure. Without that line each task gets 0 retries, the default. catchup=False stops the scheduler from creating a run for every day since start_date when the DAG is first unpaused.
Inside, each @task function becomes 1 task. extract asks for data_interval_start by naming it as a parameter, and Airflow fills it in from the run's context, so a rerun of any past day reads that day and not today. The expression load(transform(extract())) does 2 things at once: it creates the edges extract >> transform >> load, and it passes each return value to the next task as an XCom. Those values are paths, never the rows themselves.
Example DAG 1: a daily ETL with a quality gate
The signups_daily DAG with a quality gate between transform and load. Every annotation is a task argument: 2 fixed retries on the extract, a partition overwrite and a MERGE that make reruns idempotent, and a freshness target of 2 hours after the run starts at 00:00 UTC.
How to read a DAG on the pipeline canvas
The canvas maps onto Airflow 1 to 1. Transform nodes are tasks: each becomes an operator or a @task function, and its annotations (retries, backoff, idempotency, freshness) become that task's arguments. Source and storage nodes are systems outside Airflow: the API you pull from, the S3 staging area, the warehouse table you load. A quality gate is a task whose job is to fail loudly, and the default all_success rule then stops everything downstream of it.
1 mapping matters more than the rest: an arrow between 2 tasks orders the work, it does not move the data. In the daily signups DAG, extract_signups writes to S3 and transform_signups reads from S3; the edge only guarantees the write finishes before the read starts, and XCom carries the storage path across it. Candidates who have only read tutorials tend to treat the edge as a data channel, which is why interviewers ask.
The fan-in shape of the 3-source DAG is behind most warehouse loads and most data pipeline architecture patterns an interview asks you to draw. The 3 extracts run in parallel because no edge connects them, which in code is 1 line: [extract_users, extract_events, extract_billing] >> build_marts. The layers group tasks the way an Airflow TaskGroup does, and the join's all_success rule is what makes it wait for the slowest source.
Example DAG 2: fan-out and fan-in across 3 sources
The billing extract waits on a late source behind a reschedule-mode sensor whose timeout alerts instead of hanging. The join deletes and rewrites only its data interval's partition, so clearing any day reruns it cleanly.
Airflow DAG meaning: DAG vs pipeline vs workflow
In everyday use the 3 words overlap. A pipeline is the path data takes from source to destination, a workflow is any ordered set of steps, and a DAG is Airflow's definition of 1 workflow: its tasks, the order they may run in, and the settings that say when and how often. When an engineer says the DAG failed, they mean 1 run of that definition failed.
Airflow also schedules and pauses whole DAGs, and reports on each one as a unit. Retries and alerts attach to the DAG or its tasks, as do concurrency limits, and the UI groups every run and log under the DAG's id. The design decision is whether to split 1 pipeline into several DAGs or fold several into 1, because that decides what you can pause or rerun on its own and which failures alert separately.
A DAG says nothing about how data is transformed, only when each step runs. That's why teams often run SQL transformation tools inside an Airflow task rather than instead of Airflow, a split explained in dbt vs Airflow.
Know Airflow DAGs the way the interviewer who asks it knows it.
How Airflow runs a DAG: DAG runs and task instances
Airflow 3 splits the work between separate processes. The DAG processor imports every file in the DAGs folder and serialises the DAGs it finds, re-parsing each file about every 30 seconds by default. The scheduler reads those serialised DAGs, and when a schedule comes due it creates a DAG run: 1 concrete execution of the DAG, identified by its logical date and run id.
Each DAG run creates 1 task instance per task. A task instance moves through states as the scheduler and executor handle it, from scheduled to queued to running, and it finishes as success or failed unless it is skipped or goes to up for retry. The scheduler queues a task instance only when its trigger rule is satisfied by the states of its upstream task instances in the same run, which is how the edges of the graph become an order of execution.
Limits keep 1 DAG from taking over the cluster. By default a DAG runs at most 16 task instances at once (max_active_tasks) and keeps at most 16 DAG runs active (max_active_runs), and each scheduler runs at most 32 task instances in total (parallelism). Pools add a shared limit across DAGs: a 10 slot pool on the warehouse caps writes to it at 10, whichever DAGs they come from.
How to create an Airflow DAG, step by step
- 01
Define the DAG
Create a Python file in the DAGs folder that imports dag and task from airflow.sdk. Decorate a function with @dag and pass it a schedule and a start_date, with catchup=False.
- ▸Set catchup explicitly, so an old start_date never floods the scheduler with runs
- ▸Put retries and retry_delay in default_args so every task inherits them
- 02
Write the task functions
Write 1 @task function per unit of work, so extract and load are separate tasks with transform between them, and make each one safe to run twice for the same interval.
- ▸Write large data to object storage and return the path and count instead of the dataset
- ▸Import heavy libraries inside the function so the file parses fast
- 03
Wire the dependencies
With TaskFlow, pass 1 task's return value to another to create the edge; with classic operators, write extract >> transform >> load and use lists for fan-out and fan-in.
- ▸Dependencies order tasks and never move data between them
- 04
Test before you deploy
Run python on the file to catch import errors, airflow tasks test to run 1 task without the scheduler, and dag.test() to run the whole DAG in 1 process.
- ▸A unit test that loads the DAG and checks its task ids and edges catches most breakage in CI
- 05
Deploy and watch the first run
Copy the file into the deployed DAGs folder and unpause the DAG once the DAG processor picks it up. Read every task's log in the first run before you trust it.
- ▸If the DAG never appears, run airflow dags list-import-errors first
logical_date (execution_date) vs the actual time a DAG runs
Every DAG run carries a logical_date, the timestamp {{ ds }} renders, and it's often not the moment the run starts. Under the classic data interval model, a daily run covers 1 whole day and starts only after that day ends: the run that starts at 00:00 on day 2 has a data interval from day 1 00:00 to day 2 00:00, and its logical date is the start of that interval, day 1, so the run labelled Monday starts on Tuesday.
Airflow 3 changed the default. The Airflow 3 upgrade guide sets create_cron_data_intervals to False, so a bare cron string or preset such as "@daily" now resolves to CronTriggerTimetable. The run still starts at 00:00 on day 2, but its logical date is day 2 00:00, and data_interval_start equals data_interval_end. Code that reads ds and expects yesterday now reads today, a day that has only just begun.
The fix is to say which model you mean. Pass CronDataIntervalTimetable("@daily", timezone="UTC") to keep the classic interval, or CronTriggerTimetable with an interval argument, whose data interval spans that duration and ends at the trigger time. Then parameterise every query with data_interval_start and data_interval_end rather than ds or the wall clock, and a rerun of any past run reads exactly the data it read the first time. execution_date, the old name for the logical date, is gone from the task context in Airflow 3.
The run that starts at 00:00 on day 2, under each timetable
- Run starts
- logical_date
- {{ ds }} renders
- data_interval_start
- data_interval_end
- A load on ds reads
- Chosen by
- Run startsday 2 00:00
- logical_dateday 1 00:00
- {{ ds }} rendersday 1
- data_interval_startday 1 00:00
- data_interval_endday 2 00:00
- A load on ds readsthe finished day
- Chosen bypassing it explicitly
- Run startsday 2 00:00
- logical_dateday 2 00:00
- {{ ds }} rendersday 2
- data_interval_startday 2 00:00
- data_interval_endday 2 00:00
- A load on ds readsa day just begun
- Chosen bya bare cron string
Catchup: which runs a deploy creates
Catchup is the scheduler filling in intervals that never ran. With catchup=True, unpausing a daily DAG whose start_date is 90 days back makes the scheduler work through all 90 missed days: 16 run at once under the default max_active_runs, the other 74 wait their turn, and every one reads the source and writes the warehouse. With catchup=False the 89 older days are skipped and the scheduler creates at most 1 run.


Airflow 3 made catchup=False the default, but installations can override it in their configuration, so set it on every DAG and the behaviour travels with the code. When you do want history, run a backfill over the dates you choose rather than letting a deploy decide.
Backfill: running past intervals on purpose
On Airflow 3 the command airflow backfill create takes a DAG id and a date range and creates 1 run per interval in it, with options to rerun only failed runs, cap how many run at once and go newest first. Each backfilled run sees its own data interval, which is what makes a backfill correct, provided the tasks read that interval and not the current date.
Backfills and retries are only safe when rerunning a task for the same interval produces the same result. A task that appends rows doubles them on the second run; one that overwrites its partition or merges on a key does not. Interviewers treat that property as the dividing line between a demo DAG and a production one.
Airflow DAG schedule options in Airflow 3
None"@daily""0 6 * * *"CronDataIntervalTimetabletimedelta(hours=4)"@continuous"[Asset(uri)]Operators, sensors and deferrable waits
An operator defines what 1 task does. @task wraps a Python function, BashOperator runs a shell command, SQLExecuteQueryOperator runs SQL against a connection, and provider packages add operators for cloud services and databases. Most pipelines need few of them: Python tasks for logic, a SQL operator for the warehouse, and sensors for waiting.
A sensor is an operator that waits for a condition, such as a file landing in object storage or a task in another DAG finishing, and how it waits decides what it costs. Take a 60 minute wait checked every 5 minutes. In poke mode the sensor holds a worker slot for all 60 minutes. In reschedule mode it holds a slot only during each of its 13 checks and gives it back in between. A deferrable operator hands the wait to the triggerer process and runs on a worker only before it defers and after its trigger fires.
The cost shows when waits pile up. Take 1 worker with 4 slots, 4 sensors waiting on late files and 2 tasks that are ready to run. In poke mode the sensors hold all 4 slots, so the 2 ready tasks sit queued until a file lands and a sensor finishes. As deferrable operators, the same 4 waits move to the triggerer, the 2 ready tasks start at once and 2 slots stay free.


The Airflow documentation's rule of thumb is poke mode for checks every second and reschedule mode for checks every minute. For waits of many minutes or hours, and for many waiting tasks at once, deferrable operators free the most capacity, because 1 triggerer process runs many small asynchronous triggers side by side. Give every wait a timeout, so a source that never arrives fails the task and alerts someone instead of hanging.
Inside a DAG: the building blocks
A class that defines 1 task's work; a SQL operator runs a query, and others run Python or a shell command
Edges written with >> that order tasks and never move data
Operators that succeed once a file or another DAG is ready, or once a set time arrives
Small values such as paths and counts, kept in the metadata database
The upstream states a task needs; the default is all_success
expand() creates 1 task instance per input, counted at runtime
XCom: what can pass between Airflow tasks
Tasks in a DAG are isolated: they may run on different machines, in different processes, minutes apart. XCom is how they hand each other small values. With the TaskFlow API a return value is pushed as an XCom automatically, and passing it as an argument to another task pulls it and creates the dependency in the same step.
By default XComs live in Airflow's metadata database, and the documentation is explicit that they are designed for small amounts of data, never large values such as dataframes. Write the data to object storage or a table, and pass along the path or the partition. A row count is small enough to pass too. A custom XCom backend can store larger values in object storage, but the path pattern keeps each task's output inspectable and each task rerunnable on its own.
Task dependency patterns
# Linear chain: strict order
extract >> transform >> load
# Fan out, then fan in: parallel branches behind 1 extract
extract >> [clean_users, clean_events] >> build_marts
# Cleanup that runs whatever happened upstream
@task(trigger_rule="all_done")
def cleanup():
...
[clean_users, clean_events, build_marts] >> cleanup()
# Branching: choose a path at runtime, skip the other
@task.branch
def choose_path(params=None):
return "full_refresh" if params.get("full") else "incremental"
choose_path() >> [full_refresh, incremental]
# Dynamic task mapping: 1 task instance per file, counted at runtime
@task
def list_files() -> list[str]:
return ["a.csv", "b.csv", "c.csv"]
@task
def process(path: str) -> None:
...
process.expand(path=list_files())The 5 shapes that cover nearly every real DAG: chains, fan-out and fan-in, an always-run cleanup, plus 2 that decide at runtime, branching and dynamic task mapping. Separate snippets for reference, not 1 file.
Trigger rules: what still runs after a task fails
A trigger rule decides which upstream states let a task run. The default, all_success, runs a task only when every upstream task succeeded. all_done runs once every upstream task has finished in any state, which suits cleanup. one_failed runs as soon as any upstream task fails, which suits an alert task. none_failed_min_one_success runs when nothing failed and at least 1 upstream task succeeded, which is what a join after a branch needs, since the branch skips the path it didn't take.
Follow 1 failure through a DAG. When transform fails, load keeps the default rule, so Airflow marks it upstream_failed and never runs it, and publish after it gets the same state. alert, set to one_failed, runs because transform failed. cleanup, set to all_done, runs once publish has reached a final state, and upstream_failed counts as one.


Every task you need after a failure must say so in its trigger rule. Under the default, a failure silences everything downstream of it, including the alert and cleanup tasks you added for exactly that case.
How to design a DAG that handles failure
A DAG that handles failure well layers 3 mechanisms. Retries with a delay absorb transient failures such as timeouts and rate limits. A failure callback on the DAG or its tasks alerts a person once retries are used up. A cleanup task with all_done drops temporary tables or files whatever happened. For outputs that matter, a final check task that verifies the data exists catches the runs that succeeded without writing anything.
Branching and dynamic task mapping
A branch chooses a path at runtime. A function decorated with @task.branch returns the id of the task to follow and Airflow skips the others; it can also return a list of ids. Because skipped tasks are not successes, a task that joins the branches needs a trigger rule such as none_failed_min_one_success, or it will be skipped too.
Dynamic task mapping creates copies of a task at runtime. process.expand(path=list_files()) makes 1 task instance per element that list_files returns, whether that is 3 files today or 300 tomorrow, and each copy retries and logs on its own. It arrived in Airflow 2.3, and by default 1 expansion may create up to 1024 task instances. A for loop in the DAG file can also create tasks, but it runs at parse time, so the count is fixed when the file is read and any call it makes to list the inputs slows every parse.
Airflow DAG best practices: idempotent tasks and retries
Airflow reruns tasks constantly: retries after a failure, a cleared task during debugging, a backfill over last month. Each rerun is safe only when the task is idempotent, producing the same result for the same interval however many times it runs. The Airflow best practices guide puts it directly: an INSERT on a rerun can duplicate rows, so use an upsert, and never read the latest available data, because it may change between runs.
In practice that means 3 patterns: MERGE or upsert on a business key, delete then insert for the interval inside 1 transaction, or overwrite the partition the interval owns. Each ties the task's output to its data interval, never to the time it ran. The patterns and their trade-offs are covered in idempotent data pipelines.
Configure retries once, in default_args, since the default is 0. A few retries with a delay of a few minutes absorb most transient failures, and retry_exponential_backoff=True with a max_retry_delay cap suits flaky external APIs. Retries are only safe on idempotent tasks, which is why the 2 practices belong together.
Debugging a misbehaving DAG
| Symptom | Likely cause | Fix |
|---|---|---|
| The DAG never appears in the UI | The file raises an import error, or it sits outside the DAGs folder | Run airflow dags list-import-errors and confirm the file creates a DAG object at module level |
| Tasks sit in queued and never start | A pool, max_active_tasks or parallelism is full, or no worker is running | Check pool usage in the UI, raise the limit that is full, and confirm the workers are heartbeating |
| A run processes the wrong day | The code reads ds or the wall clock; on Airflow 3 a bare cron schedule gives each run an interval of 0 length | Read data_interval_start and data_interval_end under a timetable that sets them, such as CronDataIntervalTimetable |
| Unpausing starts dozens of runs | catchup=True with a start_date far in the past creates 1 run per missed interval | Set catchup=False and backfill a chosen date range on purpose with airflow backfill create |
| The whole DAG stalls behind 1 sensor | A poke-mode sensor holds a worker slot for hours while it waits | Switch to mode="reschedule" or a deferrable operator, and give the wait a timeout |
| Every DAG starts late | Top-level code in DAG files (API calls, database reads, heavy imports) runs at every parse | Move the work inside task functions and import heavy libraries inside them |
| Task code that read the metadata database fails after an upgrade | Airflow 3 no longer lets task code query the metadata database directly | Use the Airflow Python client or the REST API instead |
Keep DAG files fast to parse
The DAG processor re-imports every DAG file on a loop, about every 30 seconds by default, so any code at the top level of the file runs thousands of times a day. The top level should only build the DAG, so keep API calls, database queries, reads of Airflow Variables with Variable.get() and heavy imports such as pandas out of it. Put the work in task functions and reference Variables through templates such as {{ var.value.name }}, which resolve only when the task runs.
A slow file delays more than itself: the processor has less time for every other DAG, and changes take longer to show up. Keep 1 coherent unit of work per DAG, and when a DAG grows into unrelated parts, split it into separate DAGs connected by assets, so the downstream DAG runs when the upstream one updates its data.
How to test an Airflow DAG
Test at 3 levels. First, load the DAG in a unit test and assert that it imports without errors and has the task ids and edges you expect; running python on the file is the quickest version of the same check. This catches most breakage and belongs in CI.
Second, run 1 task in isolation with airflow tasks test, which executes it for a given logical date without the scheduler and without recording state. Third, run the whole DAG in 1 process with dag.test(), which runs every task in order and makes a debugger usable. Before a DAG goes to production, run it once in a staging environment against realistic data, including a rerun of the same interval to prove it's idempotent.
What changed for DAGs in Airflow 3
Airflow 3.0 shipped in April 2025, and most tutorials still describe Airflow 2. When you write or read a DAG, the imports change first: dag now comes from airflow.sdk, along with task and DAG. catchup defaults to False, and bare cron schedules resolve to CronTriggerTimetable, so the logical date is the moment a run starts. The context keys execution_date and prev_ds are gone, and so is next_ds.
Several features were renamed or removed. Datasets became Assets. SubDAGs are gone, replaced by TaskGroups. SLAs were removed in favour of Deadline Alerts. Task code can no longer query the metadata database directly and uses the Airflow Python client or the REST API instead. The DAG processor runs as its own process rather than inside the scheduler.
When you read an older DAG, those differences tell you its age at a glance: schedule_interval or execution_date dates it to Airflow 2 or earlier, and so do from airflow import DAG and a SubDagOperator.
How interviewers test Airflow DAG knowledge
DAG questions rarely stay on the definition. An interviewer who asks what a DAG is will follow with what happens when yesterday's run failed and today's is queued, how a DAG should alert and clean up after a failure, and how to join 3 sources that land at different times. The answers are the mechanisms on this page: independent runs unless depends_on_past is set, clearing a failed task instead of marking it success, retries plus a failure callback plus an all_done cleanup, and parallel extracts behind reschedule-mode sensors with timeouts feeding an idempotent join.
The strongest answers name the data interval. Saying that each run reads exactly its own interval, and that every write is a merge or a partition overwrite, shows you have operated DAGs rather than only written them. Work through the Airflow interview questions for the scheduler and executor follow-ups, and the data pipeline interview questions for the design questions a DAG answers.
Designing the DAG out loud is its own skill. The data pipeline practice problems ask you to draw pipelines like the 2 example DAGs on the same canvas, and the system design round guide covers where orchestration questions sit in the loop.
Airflow DAG FAQ
What is the difference between logical_date (execution_date) and the actual time a DAG runs, and why does it matter?+
What does catchup=False actually do?+
Your DAG runs daily, yesterday's run failed and today's is queued. What happens?+
How do you design a DAG that handles failures gracefully, with an alert and a cleanup task?+
How do you design a DAG that joins data from 3 sources arriving at different times?+
How many tasks should a single DAG have?+
Should I use the TaskFlow API or classic operators?+
How do I trigger a DAG manually?+
The candidate who gets the offer
- 01
Reading a solution is not the same as writing one
Every engineer who has frozen on a query they had read a dozen times knows the gap. The only preparation that closes it is producing the answer yourself, under time, before the interview does it for you
- 02
76% of hiring managers reject on the coding task, not the resume
From HackerRank's 2024 Developer Skills Report. Candidates who look strong on paper still fail the live screen if they haven't done timed, executable practice
- 03
System design comes down to the calls you defend out loud
Ingestion, batch vs streaming, the bronze/silver/gold layers, idempotency, backfill and replay. Sketching the pipeline and naming the failure modes is the signal, not the boxes
Related guides
Data pipeline interview questions
The pipeline design and operations questions interviewers ask
Data pipeline practice problems
Design pipelines on the canvas and get them graded
Airflow interview questions
Questions about how the scheduler and executors work, and about XComs and production reliability
dbt vs Airflow
How a dbt project runs inside an Airflow DAG and what a retry reruns
Compete in the Weekly
Dirty, production-shaped data, scored blind each week.