Apache Airflow Interview Questions
Reviewed by Mark Dickie · Last updated
Apache Airflow is an open-source platform for authoring, scheduling, and monitoring data workflows as code: pipelines are Python-defined DAGs of tasks, each task an operator the scheduler runs in dependency order. Interviews probe the scheduling model that trips people up — the logical (execution) date, catchup, and backfill — alongside task dependencies and trigger rules, operators versus sensors, passing state through XCom, executor choices, and why every task should be idempotent. These questions cover what separates someone who has written a DAG from someone who understands when and how the scheduler will run it.
At a glance
| Questions | 15 |
|---|---|
| Difficulty | 2–5 of 5 |
| Formats | Multiple choice, Multiple answer, True / false, Code output, Find the bug, Short answer, Flashcard, Ordering, Fill in the blank |
What you'll review
- cron presets
- bitshift operators
- trigger rules
- dynamic task mapping
- sensors
- catchup
- xcom
- branching
- templating
- idempotency
- logical date
- poke reschedule
- hooks
- task instance
- retries
Practice questions
Apache Airflow/scheduling/cron-presets
A DAG is defined with schedule="@daily" and start_date=datetime(2026, 1, 1). At what wall-clock moment does the first scheduled run actually start, and which data interval does it cover?
Options
- It starts just after midnight on 2026-01-02, covering the interval 2026-01-01 00:00 to 2026-01-02 00:00
- It starts at midnight on 2026-01-01, covering that same day forward
- It starts immediately when the DAG is unpaused, regardless of start_date
- It never runs, because
@dailyis not a valid schedule value
Show answer
@daily means 0 0 * * *. Airflow runs a schedule only after its data interval has closed, so the first run — covering 2026-01-01 00:00 to 2026-01-02 00:00 — does not fire until just after midnight on 2026-01-02. Its logical_date is the interval start (2026-01-01), the day the data belongs to. New users expect midnight on the start_date itself; the 'run after the interval ends' rule is why that intuition is wrong.
@daily is a cron preset equivalent to 0 0 * * * (midnight every day). Airflow schedules a run at the end of each data interval, not the start: the run whose interval is 2026-01-01 00:00 → 2026-01-02 00:00 is only triggered once that interval has closed, i.e. just after midnight on 2026-01-02. This 'run after the interval ends' model is the single most common source of confusion for newcomers, who expect a @daily job to fire at the start of the day. The logical_date (formerly execution_date) of that first run is the interval start, 2026-01-01, which is why the templated {{ ds }} reads as the day the data belongs to rather than the day the task ran. Option c is wrong because unpausing a DAG with catchup enabled backfills from start_date rather than running 'now'.
Apache Airflow/dependencies/bitshift-operators
Given tasks a, b, c, you write a >> [b, c] >> d. What dependency graph does this build?
Options
aruns first;bandcboth run aftera(in parallel);druns only after bothbandcfinisha,b,c,drun strictly one after another in that orderaruns, thenb, thenc, thend, butbandcmay run in either order- It raises an error: you cannot bit-shift a task into a list
Show answer
a >> [b, c] >> d builds a diamond. >> means 'set downstream', and a list fans the edge out then in: a runs first, then b and c become eligible together (they can run in parallel), and d waits for both to finish — under the default all_success rule, both must succeed. It is sugar over repeated set_downstream calls, the idiomatic way to write fan-out/fan-in.
The >> (right-bitshift) operator is overloaded in Airflow to mean 'set downstream'. Using a list on either side fans the dependency out or in: a >> [b, c] makes both b and c downstream of a, so they become eligible to run concurrently once a succeeds; [b, c] >> d makes d downstream of both, so by the default all_success trigger rule d waits for both to complete successfully. The result is a diamond. Option b describes a linear chain (a >> b >> c >> d), which is a different graph. The list syntax is purely sugar over repeated set_downstream calls and is the idiomatic way to express fan-out/fan-in without writing each edge by hand.
Apache Airflow/dependencies/trigger-rules
You have a cleanup task that must run whether or not its upstream tasks succeeded — even if one failed or was skipped. Which trigger rule should you set on cleanup?
Options
trigger_rule="all_done"trigger_rule="all_success"(the default)trigger_rule="one_success"trigger_rule="none_failed"
Show answer
Use trigger_rule="all_done". It fires the task once every direct upstream has reached any terminal state — success, failed, or skipped — which is exactly what teardown/cleanup needs. The default all_success would skip cleanup after an upstream failure; one_success fires too early and none_failed is still blocked by a failure. Note that an all_done task reports success even when upstreams failed, so the run can look green — keep separate alerting on the real failure.
all_done fires the task once every direct upstream task has reached a terminal state — success, failed, or skipped — regardless of which. That is exactly the semantics you want for teardown/cleanup work that must run no matter what happened upstream. The default all_success would skip cleanup if any upstream failed, defeating the purpose. one_success fires as soon as a single upstream succeeds (it doesn't wait for the rest), and none_failed requires that no upstream failed (skips are tolerated, but a failure blocks it) — so neither guarantees the cleanup runs after a failure. A subtle trap: with all_done the cleanup task itself reports success even when upstreams failed, so the DAG run can look green; pair it with proper alerting if upstream failure must still page someone.
Apache Airflow/dependencies/dynamic-task-mapping
Using dynamic task mapping, process.expand(file=list_files()) is written where list_files returns a list whose length is only known at runtime. When are the mapped process task instances created?
Options
- At run time by the scheduler, after
list_filescompletes — one mapped instance per element of the returned list - At DAG-parse time, by counting the elements of the list in the DAG file
- Never as separate instances;
expandruns the whole list inside one task instance - Only after a manual backfill is triggered for that DAG
Show answer
Mapped instances are created at run time by the scheduler, after the upstream list_files finishes — one instance per element, indexed by map_index. This is the whole point of dynamic task mapping (Airflow 2.3+): the count need not be known when the DAG is parsed, unlike a parse-time Python loop where it must be static. The mapped collection travels via XCom, so the upstream output must be a list or dict that XCom can store.
Dynamic task mapping (added in Airflow 2.3) defers fan-out to run time. The DAG file is parsed without knowing the mapped count; the scheduler expands process only after its upstream (list_files) has produced its output, creating one mapped task instance per element with map_index values 0..n-1. This is what makes it different from generating tasks in a Python loop at parse time, where the count must be static and known before any run. Because expansion is runtime, the mapped collection comes from XCom under the hood, so the upstream output must be a list/dict that fits XCom's storage. A correct mental model here separates parse time (DAG structure, static) from run time (mapped instances, dynamic) — confusing the two is the classic dynamic-mapping interview trap.
Apache Airflow/operators-sensors/sensors
A sensor waits for an external condition (e.g. a file landing in S3) before downstream work proceeds. Which statements about sensors are correct? Select all that apply.
Options
- In
mode="reschedule", the sensor frees its worker slot between checks instead of holding it the whole time - In the default
mode="poke", the sensor occupies a worker slot continuously while it polls - Setting a
timeoutlets a sensor fail instead of waiting forever for a condition that never arrives - A sensor in
pokemode is free of any risk of starving the pool when many run at once - Deferrable (async) sensors release the worker and resume via the triggerer, reducing slot usage further
Show answer
In the default poke mode a sensor holds a worker slot for its whole wait, so many at once can starve the pool — meaning there is a starvation risk. reschedule mode frees the slot between checks; deferrable (async) sensors go further by handing the wait to the triggerer and resuming when it fires. A timeout is essential so a never-satisfied sensor fails and alerts instead of hanging forever.
Sensors poll for a condition and have two classic modes. In poke mode (the default) the sensor sits in a running task slot for its entire wait, re-checking on poke_interval — simple, but many concurrent poke sensors can exhaust worker slots and deadlock the pool, so option d is false. In reschedule mode the sensor releases its slot between checks and is re-queued each interval, trading a little scheduling overhead for far better slot economy at scale (a). A timeout is essential so a sensor that will never be satisfied fails (and can alert) rather than hanging indefinitely (c). The modern option is deferrable sensors: they hand off the wait to the lightweight triggerer process via asyncio, freeing the worker entirely and resuming only when the trigger fires (e) — the most slot-efficient choice for long waits.
Apache Airflow/scheduling/catchup
If a DAG has catchup=True (the default in older Airflow) and a start_date two weeks in the past, unpausing it will cause the scheduler to create and run a DAG run for every missed schedule interval between the start_date and now.
Show answer
True. With catchup=True, unpausing a DAG whose start_date is two weeks back makes the scheduler create a run for every missed schedule interval up to now (throttled by max_active_runs). It is useful for backfilling history but a common foot-gun — an old start_date can launch hundreds of runs at once. Setting catchup=False skips the missed intervals and runs only the latest going forward; deliberate replays use airflow dags backfill instead.
True. catchup controls whether the scheduler 'fills in' the schedule intervals that elapsed between start_date and the present. With catchup=True, unpausing a DAG whose start_date is two weeks back triggers a run for each missed interval (subject to max_active_runs), which is great for backfilling historical data but a notorious foot-gun: people set a months-old start_date, unpause, and accidentally launch hundreds of runs that hammer a database or external API. The defensive defaults are to set catchup=False so only the most recent interval runs going forward, and/or pin max_active_runs to throttle concurrency. Note that catchup=False does not run 'all missed intervals as one' — it simply skips them and starts from the latest completed interval. To replay a specific historical window deliberately, you use airflow dags backfill rather than relying on catchup.
Apache Airflow/data-state/xcom
Using the TaskFlow API, what value does show receive and print when this DAG run executes?
from airflow.decorators import dag, task
from datetime import datetime
@dag(start_date=datetime(2026, 1, 1), schedule=None, catchup=False)
def pipeline():
@task
def make():
return {"rows": 42}
@task
def show(payload):
print(payload["rows"])
show(make())
pipeline()Options
- It prints
42 - It prints
{'rows': 42} - It prints
None, because TaskFlow return values are not passed automatically - It raises a TypeError:
makereturns a coroutine, not a dict
Show answer
It prints `42`
The TaskFlow API (@task) makes XCom passing implicit: a task's return value is pushed to XCom, and passing make() as the argument to show() wires up the dependency and pulls that value back as the function argument at run time. So show receives the actual dict {"rows": 42} and payload["rows"] prints 42. Option c is the pre-TaskFlow mental model — with classic operators you'd push/pull XCom manually via ti.xcom_push/xcom_pull, but the decorator handles it for you. Option d misreads @task as async; it is an ordinary synchronous Python callable wrapped as an operator. The key insight is that calling a TaskFlow function in the DAG body does not execute it inline — it registers a task and returns an XComArg placeholder that resolves to the real value only when the task runs.
Apache Airflow/dependencies/branching
A BranchPythonOperator named pick returns the string "path_a". Tasks path_a and path_b are both downstream of pick, and a task join is downstream of both with the default trigger rule. After the DAG run, what are the states of path_b and join?
def choose():
return "path_a"
pick = BranchPythonOperator(task_id="pick", python_callable=choose)
path_a = EmptyOperator(task_id="path_a")
path_b = EmptyOperator(task_id="path_b")
join = EmptyOperator(task_id="join")
pick >> [path_a, path_b] >> joinOptions
path_bis skipped;joinis also skipped, because its defaultall_successrule treats a skipped upstream as not-successpath_bis skipped butjoinstill succeedspath_bruns anyway; branching only affects loggingpath_bfails, causing the whole DAG run to fail
Show answer
`path_b` is skipped; `join` is also skipped, because its default `all_success` rule treats a skipped upstream as not-success
BranchPythonOperator returns the task_id(s) to follow; every other directly-downstream task is skipped (not failed). So path_b is skipped. The trap is join: with the default all_success trigger rule, a skip propagates — all_success is not satisfied when an upstream is skipped, so join is skipped too, silently stranding the rest of the pipeline. This is one of the most common branching bugs. The standard fix is to set join's trigger rule to none_failed_min_one_success (or the older none_failed_or_skipped), which lets it run as long as no upstream failed and at least one succeeded — so a skipped branch is tolerated. Option d is wrong because branching produces skips, never failures.
Apache Airflow/data-state/templating
A DAG with schedule="@daily" runs the data interval starting 2026-03-14. A BashOperator has bash_command="echo {{ ds }}". What does the rendered command echo for that run?
BashOperator(
task_id="print_date",
bash_command="echo {{ ds }}",
)Options
- 2026-03-14
- 2026-03-15 (the day the task actually runs)
- {{ ds }} (the template is printed literally, unrendered)
- The current system date at render time
Show answer
2026-03-14
{{ ds }} is a Jinja template macro that renders to the logical_date (the data interval start) formatted as YYYY-MM-DD — here 2026-03-14, the day the data belongs to, not the day the task physically runs. Airflow renders templated fields like bash_command against the task's runtime context before execution, so option c (literal, unrendered) is wrong: bash_command is a templated field. Option b is the classic confusion between logical date and wall-clock run time; because a @daily run for the 2026-03-14 interval fires just after midnight on 2026-03-15, the wall-clock day differs from ds, but {{ ds }} deliberately tracks the data date so your queries and file paths are idempotent and reproducible on re-run. {{ data_interval_start }} gives the same instant as a full timestamp.
Apache Airflow/reliability/idempotency
This daily ETL task works on the first run but corrupts the warehouse when Airflow retries it or when the interval is backfilled. What is the root cause?
@task
def load_daily(**context):
ds = context["ds"]
rows = extract_rows_for(ds)
# append today's rows into the target table
warehouse.execute(
"INSERT INTO sales SELECT * FROM staging WHERE day = %s",
(ds,),
)Options
- The task is not idempotent: a blind INSERT appends the same day's rows again on every retry or backfill, producing duplicates. It should delete/overwrite that partition first (or use an upsert/MERGE) so re-running the same logical_date yields the same result
context["ds"]is invalid; you must usecontext["execution_date"]instead- The SQL is vulnerable to injection because it uses a parameter placeholder
@taskfunctions cannot accept**context, so this never runs
Show answer
The task is not idempotent: a blind INSERT appends the same day's rows again on every retry or backfill, producing duplicates. It should delete/overwrite that partition first (or use an upsert/MERGE) so re-running the same logical_date yields the same result
The bug is a violation of idempotency, the single most important property of an Airflow task. Airflow will run a task more than once for the same logical_date — on retry after a failure, on a manual re-run, or during a backfill — and a task must produce the same end state each time. This INSERT ... SELECT blindly appends the day's rows, so the second execution duplicates them. The fix is to make the write idempotent for the partition keyed by ds: delete-then-insert that day's partition, use INSERT OVERWRITE/MERGE/upsert, or write to an ds-scoped location you replace wholesale. Option b is wrong — ds is a valid context key (and execution_date is the deprecated name); option c misreads a safe parameterized query as injection; option d is wrong because **context is the standard way a task receives the runtime context. Designing every task to be safely re-runnable is what makes retries and backfills trustworthy.
Apache Airflow/scheduling/logical-date
Explain the difference between a DAG run's logical_date (formerly execution_date) and the wall-clock time the task actually runs, and why building tasks around logical_date matters.
Show answer
The logical_date (renamed from execution_date) is the timestamp identifying the data interval a DAG run is responsible for — conventionally the start of that interval — not the moment the task executes. Airflow schedules a run only after its data interval has closed, so a @daily run for 2026-03-14 fires just after midnight on 2026-03-15: the wall-clock run time is a day later than the logical_date. Tasks should key their work off logical_date (via macros like {{ ds }} or {{ data_interval_start }}/{{ data_interval_end }}) rather than the real current time, because that is what makes them idempotent and reproducible: a retry, a manual re-run, or a backfill of the same logical_date will query the same window and produce the same result. Relying on datetime.now() instead would make every re-run read a different window, breaking backfills and retries.
logical_date marks the data interval a run owns (its start), while the task may execute much later — typically after the interval closes. The interview point is that tasks should be written against logical_date ({{ ds }}, {{ data_interval_start }}) instead of datetime.now(), because that decouples what data a run processes from when it happens to run. That decoupling is precisely what lets retries, manual re-runs, and backfills all process the identical window and stay idempotent. A strong answer connects logical_date to reproducibility/backfill safety; a weak one just defines the term without explaining why now() is dangerous.
Apache Airflow/operators-sensors/poke-reschedule
A sensor must wait up to several hours for an upstream file. Compare poke mode and reschedule mode for this sensor, and explain when each is appropriate.
Show answer
In poke mode (the default) the sensor stays in a running state and holds its worker/task slot for the entire wait, re-checking the condition every poke_interval. It's simple and low-latency but expensive at scale: many long-running poke sensors can occupy all worker slots at once and deadlock the pool, since no other tasks can be scheduled. In reschedule mode the sensor checks the condition, and if it isn't met it releases its slot and goes back to an 'up_for_reschedule' state, getting re-queued to check again after poke_interval — so it consumes a slot only briefly during each check. For a multi-hour wait, reschedule mode (or, better, a deferrable/async sensor that hands the wait to the triggerer via asyncio) is the right choice because it frees worker capacity between checks. Poke mode is fine for short waits where the scheduling overhead of repeated re-queuing isn't worth it and you want the condition met as quickly as possible.
The core tradeoff is slot occupancy. Poke mode holds a worker slot the entire wait — fine for short waits, dangerous at scale because concurrent long pokes can starve/deadlock the pool. Reschedule mode releases the slot between checks (the sensor sits in up_for_reschedule), trading a little re-queue overhead for far better capacity use on long waits. For multi-hour waits the best modern answer is a deferrable sensor: it offloads the wait to the lightweight triggerer process via asyncio and consumes no worker slot at all. A strong answer names the slot-starvation risk of poke and recommends reschedule/deferrable for long waits.
Apache Airflow/data-state/hooks
What is a Hook in Airflow, and how does it relate to Connections and Operators?
Show answer
A Hook is a reusable interface to an external system — a database, cloud service, API, or message queue (e.g. PostgresHook, S3Hook, HttpHook). It encapsulates the boilerplate of authenticating and talking to that system: opening clients, running queries, uploading files. Crucially, a Hook does not store credentials itself — it looks them up from a Connection (referenced by conn_id), which Airflow keeps in its metadata database (with secrets ideally backed by a secrets backend). This keeps credentials out of DAG code. Operators are the task-level building blocks you place in a DAG; many operators use Hooks under the hood to do their actual work (for instance, a transfer operator may use two Hooks). When you need custom logic that no existing operator covers, you typically call a Hook directly inside a @task/PythonOperator function, getting the connection management for free while writing your own flow.
The clean mental model is a three-layer separation: Connections store the credentials/endpoint (in the metadata DB, keyed by conn_id), Hooks are the reusable client that reads a Connection and talks to the external system, and Operators are the DAG-level task units that often delegate to Hooks. The interview-critical detail is that Hooks pull credentials from Connections rather than hard-coding them, which is how Airflow keeps secrets out of DAG source. Calling a Hook directly inside a custom task is the idiomatic escape hatch when no off-the-shelf operator fits.
Apache Airflow/core-concepts/task-instance
Order these stages of a single task instance's lifecycle, from the moment its DAG run begins to successful completion, top to bottom.
Put these in order
- no_status — the task instance exists in the DAG run but its upstream dependencies are not yet met
- scheduled — the scheduler has determined dependencies are met and the task is eligible to run
- queued — the executor has accepted the task and placed it in its work queue
- running — a worker has picked up the task and is executing it
- success — the task finished without error and downstream tasks become eligible
Show answer
A task instance progresses: no_status (waiting on upstream deps) → scheduled (the scheduler judged deps met and slots available) → queued (the executor accepted it onto its work queue) → running (a worker is executing it) → success (clean exit, unblocking downstream). The scheduler owns the move into scheduled; the executor and workers own queued and running. A task stuck in scheduled means exhausted pool/concurrency; stuck in queued points at the executor or missing workers.
A task instance moves through distinct states driven by the separation between the scheduler and the executor. It starts with no status while waiting on upstream dependencies; once the scheduler sees those dependencies satisfied (and pool/concurrency limits allow), it marks the instance scheduled. The scheduler then hands it to the executor, which queues it; a worker eventually picks it up and the instance goes running; on a clean exit it becomes success, which is what unblocks downstream tasks. Understanding this pipeline matters operationally: a task stuck in scheduled usually points to exhausted pool slots or max_active_tasks, while one stuck in queued points to the executor or workers (e.g. no Celery workers available, or a Kubernetes pod that can't be scheduled). Knowing which component owns which state is how you debug a 'why isn't my task running' incident.
Apache Airflow/reliability/retries
To make a task automatically re-run up to three times after a failure, you set the _____ argument to 3. To wait a fixed gap between those attempts, you set retry_delay; to make that gap grow after each failure instead of staying constant, you enable _____=True.
Show answer
To make a task automatically re-run up to three times after a failure, you set the **retries** argument to 3. To wait a fixed gap between those attempts, you set retry_delay; to make that gap grow after each failure instead of staying constant, you enable **retry_exponential_backoff**=True.
retries sets how many times Airflow re-attempts a task instance after it fails before marking it failed for good — a per-task (or default_args) integer, so retries=3 allows three additional attempts. retry_delay (a timedelta) is the wait between attempts. By default that delay is constant; setting retry_exponential_backoff=True makes the wait grow after each failure (roughly doubling, capped by max_retry_delay), which is the standard pattern for transient failures against rate-limited or temporarily-overloaded external systems — backing off avoids hammering a struggling dependency. Retries only help when tasks are idempotent: a re-run must be safe to repeat, otherwise automatic retries can compound the damage of a partial first attempt.
Related interview questions
Practice this for real
CodePrep turns your target job description into an adaptive quiz from a bank of tagged questions, scores your answers, and resurfaces the topics you miss.