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.

Last updated: Proudly published by: Jeff Wahl20 min read

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

API
signups api
Airflow
extract signups
RETRY2BACKOFFfixed
S3
s3 staging
Airflow
transform signups
IDEMPOTENCYdelete write
custom
signup checks
ERRORalertMONITORSlack on failure
Airflow
load warehouse
SLA< 2hIDEMPOTENCYupsert
warehouse
warehouse signups

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

sources
extract tasks
staging
join
quality gate
consumers
PostgreSQL
users db
Kafka
events stream
API
billing api
Airflow
extract users
RETRY3BACKOFFexponential
Airflow
extract events
Airflow
extract billing
ERRORalert
S3
staged lake
Airflow
build marts
BACKFILLpartition overwriteIDEMPOTENCYdelete write
custom
mart checks
MONITORPagerDuty on failure
dashboard
revenue dashboard

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.

Prepare for the interview
01 / Open invite
02min.

Know Airflow DAGs the way the interviewer who asks it knows it.

a Airflow DAGs query, the same shape a screen would give you.
The diff against expected. Where ties broke. What you missed.
sandbox
1source → bronze → silver → gold
2 ingest : CDC + Kafka
3 transform : dbt + Airflow
4 serve : Snowflake
5
Execute your solution0.4s avg.
AppleInterview question
Solve a Airflow DAGs problem

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

  1. 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
  2. 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
  3. 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
  4. 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
  5. 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

CronDataIntervalTimetable
  • 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
CronTriggerTimetable
  • 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.

A daily DAG unpaused with a start_date 90 days back: with catchup=True it works through 90 runs, 16 running at a time and 74 waiting; with catchup=False it skips 89 days and runs at most 1A daily DAG unpaused with a start_date 90 days back: with catchup=True it works through 90 runs, 16 running at a time and 74 waiting; with catchup=False it skips 89 days and runs at most 1

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
Never scheduled; a person or another DAG has to trigger it, directly or through the API
"@daily"
Starts at midnight in the DAG's timezone, and on Airflow 3 the logical date is the moment it starts
"0 6 * * *"
A cron string, here 06:00 every day, resolved to the same timetable as a preset
CronDataIntervalTimetable
Starts at the same cron times but stamps each run with the start of the interval it covers
timedelta(hours=4)
Runs every 4 hours counted from start_date, not aligned to clock times
"@continuous"
Starts a new run as soon as the last one finishes
[Asset(uri)]
Runs when a task that lists the asset in its outlets updates it

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.

A worker with 4 slots in 2 cases: with poke-mode sensors, 4 waiting sensors fill every slot and 2 ready tasks sit queued; with deferrable operators, the waits move to the triggerer, 2 tasks run and 2 slots are freeA worker with 4 slots in 2 cases: with poke-mode sensors, 4 waiting sensors fill every slot and 2 ready tasks sit queued; with deferrable operators, the waits move to the triggerer, 2 tasks run and 2 slots are 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.

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.

A DAG run after the task transform failed: load, with the default all_success rule, and publish after it are marked upstream_failed and do not run, while alert, with trigger rule one_failed, and cleanup, with trigger rule all_done, still runA DAG run after the task transform failed: load, with the default all_success rule, and publish after it are marked upstream_failed and do not run, while alert, with trigger rule one_failed, and cleanup, with trigger rule all_done, still run

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

SymptomLikely causeFix
The DAG never appears in the UIThe file raises an import error, or it sits outside the DAGs folderRun airflow dags list-import-errors and confirm the file creates a DAG object at module level
Tasks sit in queued and never startA pool, max_active_tasks or parallelism is full, or no worker is runningCheck pool usage in the UI, raise the limit that is full, and confirm the workers are heartbeating
A run processes the wrong dayThe code reads ds or the wall clock; on Airflow 3 a bare cron schedule gives each run an interval of 0 lengthRead data_interval_start and data_interval_end under a timetable that sets them, such as CronDataIntervalTimetable
Unpausing starts dozens of runscatchup=True with a start_date far in the past creates 1 run per missed intervalSet catchup=False and backfill a chosen date range on purpose with airflow backfill create
The whole DAG stalls behind 1 sensorA poke-mode sensor holds a worker slot for hours while it waitsSwitch to mode="reschedule" or a deferrable operator, and give the wait a timeout
Every DAG starts lateTop-level code in DAG files (API calls, database reads, heavy imports) runs at every parseMove the work inside task functions and import heavy libraries inside them
Task code that read the metadata database fails after an upgradeAirflow 3 no longer lets task code query the metadata database directlyUse 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?+
The logical date identifies the run; the actual time is when it starts. With a data interval timetable, a daily run that starts at 00:00 on day 2 has logical date day 1, the start of the day it processes. With Airflow 3's default for bare cron strings, the same run has logical date day 2 and an interval of 0 length. Code that reads ds as yesterday processes the wrong day under the second model, so pick the timetable explicitly and query by data_interval_start and data_interval_end. execution_date was the Airflow 2 name and is gone from the Airflow 3 context.
What does catchup=False actually do?+
When a DAG is unpaused, catchup=True makes the scheduler create 1 run for every interval between start_date and now that never ran. A daily DAG with a start_date 90 days back gets 90 runs, 16 of them active at a time by default. catchup=False creates only the latest interval. Airflow 3 made False the default, but set it explicitly so the DAG behaves the same on every installation, and backfill history on purpose with airflow backfill create.
Your DAG runs daily, yesterday's run failed and today's is queued. What happens?+
By default today's run goes ahead, because runs are independent unless a task sets depends_on_past=True, in which case that task waits for its own run in the prior DAG run to succeed. To recover, read the failed task's log and fix the cause. Then clear the failed task instance: clearing re-queues it and increments its try number, while marking it success skips the work entirely. Clear only the failed task and its downstream tasks, not the whole run, when the upstream output is good.
How do you design a DAG that handles failures gracefully, with an alert and a cleanup task?+
Retries with a delay on each task absorb transient failures. A failure callback on the DAG sends the alert once retries are used up; Slack and PagerDuty are common targets, and so is email. A cleanup task with trigger_rule all_done runs whatever happened upstream. For outputs that matter, add a final task that checks the data actually exists.
How do you design a DAG that joins data from 3 sources arriving at different times?+
Run 3 extract tasks in parallel, each writing its interval's data to a staging location, and make the join depend on all 3 with the default all_success rule. Put a reschedule-mode sensor or a deferrable operator with a timeout in front of each late source, so a missing source fails and alerts instead of hanging, and make the join delete and rewrite only its own interval so a rerun is safe.
How many tasks should a single DAG have?+
Airflow sets no hard limit, and DAGs with hundreds of tasks run in production. The practical limit is readability and parse time: when a DAG mixes unrelated work, or 1 failure is hard to find in the graph view, split it into focused DAGs connected by assets or by a task that triggers the downstream DAG.
Should I use the TaskFlow API or classic operators?+
Use the TaskFlow API for Python work in new DAGs: the @task decorator infers dependencies from function calls and passes return values as XComs for you. Use classic operators for sensors and the provider operators that talk to external systems. Both styles mix freely in 1 DAG.
How do I trigger a DAG manually?+
Press the trigger button in the UI, optionally with a JSON configuration, run airflow dags trigger with the DAG id on the command line, or call the REST API's DAG runs endpoint. The run's tasks read any configuration you pass through params. On Airflow 3, do not assume a manual run's data interval derives from the logical date you pass.
02 / Why practice

The candidate who gets the offer

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

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

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