This page requires JavaScript to display.
A
Unpacking...
← Data Engineering
Data Engineering

Apache Airflow Reference

Workflows as code: architecture, DAGs with the TaskFlow API, scheduling and data intervals, operators and sensors, XCom, branching, dynamic tasks, assets, Spark/Databricks/Glue orchestration, testing and production.

Contents
1. What Apache Airflow Is 2. Architecture 3. Your First DAG (TaskFlow API) 4. Scheduling, Data Intervals & Catchup 5. Operators, Sensors & Hooks 6. Passing Data Between Tasks (XCom) 7. Branching, Trigger Rules & Dynamic Tasks 8. Data-Aware Scheduling with Assets 9. Orchestrating Spark, Databricks, Glue & dbt 10. Connections, Variables & Secrets 11. Retries, Alerts & Resource Control 12. Testing & Debugging DAGs 13. Running Airflow in Production 14. Best Practices 15. Airflow vs Other Orchestrators 16. Common Interview Questions

1. What Apache Airflow Is

Apache Airflow is an open-source platform to author, schedule and monitor workflows as code. A workflow is a DAG (directed acyclic graph) of tasks written in Python: Airflow decides when each run should happen, runs tasks in dependency order on workers, retries failures, and shows everything in a web UI. Airflow orchestrates: the heavy processing usually happens elsewhere (Spark, Databricks, Glue, a warehouse, dbt).

DAG
A workflow: tasks plus the dependencies between them, with a schedule.
Task
One unit of work in a DAG, created from an operator or a @task function.
Operator
A template for a kind of task: run Bash, a Spark job, a SQL query, a Glue job…
Sensor
A task that waits for something: a file, a partition, another DAG.
DAG run
One execution of a DAG for a specific data interval (e.g. "2026-10-25").
Task instance
One task within one DAG run, with its own state, logs and retries.

This guide uses Airflow 3 syntax (released 2025). Where Airflow 2.x differs, it is called out. MWAA, Cloud Composer and many companies still run 2.x, so you will meet both.

2. Architecture

            ┌────────────── metadata database (Postgres / MySQL) ──────────────┐
            │  DAG runs, task states, connections, variables, XComs, history    │
            └───────▲───────────────▲───────────────▲───────────────▲───────────┘
                    │               │               │               │
  dags/ folder ─► DAG processor   Scheduler      API server       Triggerer
  (Python files)  (parses DAG     (decides what  (web UI + REST   (async waits for
                   files)          runs, queues   API; workers    deferrable tasks)
                                   tasks)         report through it)
                                      │
                                      ▼  executor
                               Workers (Celery / Kubernetes pods / local processes)
ComponentRole
SchedulerCreates DAG runs when they are due, checks dependencies, and hands ready tasks to the executor
DAG processorParses DAG files and stores serialized DAGs (a separate process in Airflow 3)
API server / webserverServes the UI and REST API. In Airflow 3, workers talk to the API server through the Task SDK instead of the database directly
ExecutorHow tasks run: LocalExecutor (processes on one machine), CeleryExecutor (a pool of worker machines), KubernetesExecutor (a pod per task)
TriggererRuns asyncio "triggers" for deferrable operators, so waiting tasks do not occupy worker slots
Metadata DBThe source of truth for state; back it up and keep it healthy

3. Your First DAG (TaskFlow API)

The TaskFlow API turns Python functions into tasks with @task. Return values are passed between tasks automatically (through XCom), and the dependencies follow from how the functions call each other.

from datetime import datetime, timedelta
from airflow.sdk import dag, task  # Airflow 2.x: from airflow.decorators import dag, task

@dag(
    schedule="0 2 * * *",  # 02:00 every day (Airflow 2.x also accepted schedule_interval)
    start_date=datetime(2026, 10, 1),
    catchup=False,  # don't create runs for every missed interval
    default_args={"owner": "data-platform", "retries": 2, "retry_delay": timedelta(minutes=5)},
    tags=["fraud", "daily"],
)
def daily_transactions():

    @task
    def extract(ds=None) -> str:  # ds = the run's date (YYYY-MM-DD), injected from the context
        return f"s3://acme-raw/transactions/{ds}/"

    @task
    def validate(path: str) -> int:
        rows = count_rows(path)              # your helper
        if rows == 0:
            raise ValueError(f"no data in {path}")  # fails the task → retries → alert
        return rows

    @task
    def load(path: str, rows: int) -> None:
        print(f"loading {rows} rows from {path} into silver.transactions")

    path = extract()
    load(path, validate(path))  # dependencies: extract → validate → load

daily_transactions()

Put the file in the dags/ folder; the DAG processor picks it up, and it appears in the UI as daily_transactions.

The same DAG with classic operators

from datetime import datetime
from airflow.sdk import DAG
from airflow.providers.standard.operators.bash import BashOperator  # Airflow 2.x: airflow.operators.bash
from airflow.providers.standard.operators.empty import EmptyOperator

with DAG(dag_id="spark_daily", schedule="@daily",
         start_date=datetime(2026, 10, 1), catchup=False) as dag:

    start = EmptyOperator(task_id="start")
    enrich = BashOperator(
        task_id="spark_enrich",
        bash_command="spark-submit jobs/enrich.py --run-date {{ ds }}",  # Jinja template
    )
    publish = BashOperator(task_id="publish", bash_command="python jobs/publish.py {{ ds }}")
    done = EmptyOperator(task_id="done")

    start >> enrich >> publish >> done  # bit-shift operators set dependencies
    # fan-out / fan-in:  start >> [a, b, c] >> done

4. Scheduling, Data Intervals & Catchup

Each DAG run covers a data interval. A run is created when its interval ends, because only then is the data for that interval complete. This is the most common source of confusion in Airflow interviews.

schedule = "0 2 * * *"   (daily at 02:00)

data interval:   2026-10-25 02:00  ──────────────►  2026-10-26 02:00
logical date:    2026-10-25 02:00  (= start of the interval; {{ ds }} = "2026-10-25")
run starts:                                          2026-10-26 02:00  (after the interval closes)
Schedule valueMeaning
"@daily" / "@hourly" / "@weekly"Presets (midnight daily, top of every hour, Sunday midnight)
"0 6 * * 1-5"Cron: 06:00 on weekdays
timedelta(hours=6)Every 6 hours from start_date
NoneOnly when triggered manually or by the API
[orders_asset]Data-aware: run when an upstream asset is updated (see section 9)
A timetable objectCustom calendars, e.g. business days only, or month-end
SettingWhat it doesWatch out for
start_dateFirst interval Airflow considersUse a fixed date, never datetime.now()
catchupIf True, create runs for every interval between start_date and nowDefault is False in Airflow 3 (True in 2.x): a new DAG with an old start_date could otherwise launch hundreds of runs
BackfillDeliberately run past intervalsAirflow 3: airflow backfill create --dag-id d --from-date 2026-09-01 --to-date 2026-09-30; 2.x: airflow dags backfill
max_active_runsLimit concurrent runs of the DAGSet to 1 for jobs that must run in order

Templates and run-specific values

Use the run's logical date, never "today", so reruns and backfills process the right data:

{{ ds }}                      → 2026-10-25                  (logical date)
{{ ds_nodash }}               → 20261025
{{ data_interval_start }}     → 2026-10-25T02:00:00+00:00
{{ data_interval_end }}       → 2026-10-26T02:00:00+00:00
{{ macros.ds_add(ds, -1) }}   → 2026-10-24
{{ params.env }}              → value from the DAG's params
{{ var.value.raw_bucket }}    → an Airflow Variable
{{ conn.my_postgres.host }}   → a field of a connection

# in TaskFlow code, read the context directly:
from airflow.sdk import get_current_context
ctx = get_current_context()
start = ctx["data_interval_start"]

Airflow 3 removed execution_date: use logical_date (or data_interval_start). Templates are only rendered in fields an operator lists as templated (such as bash_command, sql, and many *_args parameters).

5. Operators, Sensors & Hooks

KindExamplesPackage
Core / standardBashOperator, PythonOperator, EmptyOperator, BranchPythonOperatorapache-airflow-providers-standard
SQLSQLExecuteQueryOperator (Postgres, Snowflake, Redshift…)apache-airflow-providers-common-sql
AWSGlueJobOperator, EmrServerlessStartJobOperator, AthenaOperator, S3KeySensor, RedshiftDataOperatorapache-airflow-providers-amazon
DatabricksDatabricksRunNowOperator, DatabricksSubmitRunOperator, DatabricksSqlOperatorapache-airflow-providers-databricks
Azure / GCPAzureDataFactoryRunPipelineOperator, BigQueryInsertJobOperator, Dataproc operatorsMicrosoft / Google providers
Spark / KubernetesSparkSubmitOperator, KubernetesPodOperatorApache Spark / CNCF Kubernetes providers

Hooks are the reusable clients behind operators (e.g. S3Hook, PostgresHook). Use a hook inside your own task when no operator fits; it reads credentials from an Airflow connection.

Sensors: wait for data, efficiently

from airflow.providers.amazon.aws.sensors.s3 import S3KeySensor

wait_for_file = S3KeySensor(
    task_id="wait_for_success_marker",
    bucket_key="s3://acme-raw/landing/transactions/{{ ds }}/_SUCCESS",
    aws_conn_id="aws_default",
    deferrable=True,  # hand the wait to the triggerer: no worker slot used
    poke_interval=300,            # check every 5 minutes
    timeout=6 * 60 * 60,  # give up (fail) after 6 hours → alert
)
wait_for_file >> enrich
Sensor modeBehaviour
poke (default)Holds a worker slot for the whole wait. Fine only for short waits
rescheduleReleases the slot between checks
deferrable=TrueSuspends the task and lets the triggerer wait asynchronously. Best for long waits at scale

6. Passing Data Between Tasks (XCom)

XCom ("cross-communication") stores small values in the metadata DB so one task can pass them to another. TaskFlow return values are XComs automatically.

@task
def find_partitions(ds=None) -> list[str]:
    return [f"event_date={ds}/channel={c}" for c in ("UPI", "CARD", "ATM")]  # small: fine

@task
def report(partitions: list[str]) -> None:
    print(len(partitions), "partitions processed")

report(find_partitions())

# classic operators: read another task's XCom in a template
#   "{{ ti.xcom_pull(task_ids='find_partitions') }}"

Pass references, not data: an S3 path, a table name, a row count. Never pass DataFrames or large files through XCom; it lives in the metadata database. Large values need a custom object-storage XCom backend, and even then should be avoided.

7. Branching, Trigger Rules & Dynamic Tasks

Branching

@task.branch
def choose_load_type(ds=None) -> str:
    from datetime import date
    day = date.fromisoformat(ds)
    return "full_reload" if day.day == 1 else "incremental_load"  # task_id to follow

@task
def full_reload(): ...

@task
def incremental_load(): ...

@task(trigger_rule="none_failed_min_one_success")  # run after whichever branch ran
def publish(): ...

choose_load_type() >> [full_reload(), incremental_load()] >> publish()
Trigger ruleRun the task when…
all_success (default)Every upstream task succeeded
all_doneEvery upstream finished, whatever the result (cleanup tasks)
one_failedAt least one upstream failed (alerting tasks)
none_failed_min_one_successNothing failed and at least one succeeded (joins after a branch, since skipped ≠ failed)
all_failed, one_success, none_skipped, alwaysRarer cases

Dynamic task mapping

Create one task instance per item at runtime, without knowing the count in advance:

@task
def list_new_files(ds=None) -> list[str]:
    return s3_list(f"s3://acme-raw/landing/{ds}/")  # e.g. 37 files today, 52 tomorrow

@task(max_active_tis_per_dag=8)  # at most 8 in parallel
def load_file(path: str) -> int:
    return copy_into_bronze(path)

@task
def total(counts: list[int]) -> None:
    print("rows loaded:", sum(counts))

total(load_file.expand(path=list_new_files()))  # one load_file per file

.partial(conn_id="x").expand(path=…) fixes some arguments and maps others; expand_kwargs maps over a list of argument dicts. Each mapped instance retries independently.

Task groups

from airflow.sdk import task_group

@task_group
def bronze_to_silver(table: str):
    validate_bronze(table) >> merge_into_silver(table)  # tasks defined elsewhere with @task

for t in ["transactions", "customers", "accounts"]:
    bronze_to_silver.override(group_id=f"silver_{t}")(t)

Task groups organise the UI. SubDAGs were removed in Airflow 3; use task groups, or separate DAGs connected with assets.

8. Data-Aware Scheduling with Assets

Instead of guessing times ("silver usually finishes by 03:00, so start gold at 03:30"), let a DAG run when the data it needs has been updated. A task declares the asset it produces; consumer DAGs schedule on it.

from airflow.sdk import Asset, dag, task  # Airflow 2.x: from airflow.datasets import Dataset

silver_orders = Asset("s3://acme-lake/silver/orders")

@dag(schedule="@hourly", start_date=datetime(2026, 10, 1), catchup=False)
def build_silver_orders():
    @task(outlets=[silver_orders])  # marks the asset updated when this task succeeds
    def merge_orders():
        ...
    merge_orders()

@dag(schedule=[silver_orders], start_date=datetime(2026, 10, 1), catchup=False)
def build_gold_sales():  # runs after each successful silver update
    @task
    def aggregate():
        ...
    aggregate()

build_silver_orders()
build_gold_sales()

Schedule on several assets (schedule=[a, b] waits for both) or combine with time using asset-or-time schedules. Assets also give cross-DAG lineage in the UI. In Airflow 2.4–2.10 the same feature is called Datasets.

9. Orchestrating Spark, Databricks, Glue & dbt

from airflow.providers.databricks.operators.databricks import DatabricksRunNowOperator
from airflow.providers.amazon.aws.operators.glue import GlueJobOperator
from airflow.providers.amazon.aws.operators.emr import EmrServerlessStartJobOperator

run_databricks = DatabricksRunNowOperator(
    task_id="silver_merge",
    databricks_conn_id="databricks_default",
    job_id=123456,  # an existing Databricks job (Asset Bundle)
    job_parameters={"run_date": "{{ ds }}"},
    deferrable=True,
)

run_glue = GlueJobOperator(
    task_id="raw_to_curated",
    job_name="raw_to_curated",
    script_args={"--run_date": "{{ ds }}"},
    aws_conn_id="aws_default",
    wait_for_completion=True,
)

run_emr = EmrServerlessStartJobOperator(
    task_id="enrich_on_emr",
    application_id="00fabcdexample",
    execution_role_arn="arn:aws:iam::123456789012:role/emr-serverless-job",
    job_driver={"sparkSubmit": {
        "entryPoint": "s3://acme-code/jobs/enrich.py",
        "entryPointArguments": ["--run-date", "{{ ds }}"],
    }},
)
# dbt: run models for the day, then test them
dbt_run = BashOperator(task_id="dbt_run",
                       bash_command="cd /opt/dbt && dbt build --select tag:daily --vars '{run_date: {{ ds }}}'")

10. Connections, Variables & Secrets

Connections hold credentials and endpoints (host, login, password, extras) referenced by ID such as aws_default. Variables hold small configuration values. Both can live in the metadata DB, in environment variables, or (best) in a secrets backend.

# environment variables (handy in containers and CI)
export AIRFLOW_CONN_WAREHOUSE='postgresql://etl_user:s3cret@warehouse.internal:5432/analytics'
export AIRFLOW_VAR_RAW_BUCKET='acme-raw'

# CLI
airflow connections add aws_default --conn-type aws --conn-extra '{"region_name": "ap-south-1"}'
airflow variables set env prod
from airflow.sdk import Variable  # Airflow 2.x: from airflow.models import Variable
from airflow.providers.postgres.hooks.postgres import PostgresHook

@task
def row_count(table: str) -> int:
    env = Variable.get("env", default="dev")  # read inside tasks, not at the top of the file
    hook = PostgresHook(postgres_conn_id="warehouse")
    return hook.get_first(f"SELECT COUNT(*) FROM {env}_{table}")[0]

11. Retries, Alerts & Resource Control

from datetime import timedelta

def notify_failure(context):
    ti = context["ti"]
    send_slack(f":red_circle: {ti.dag_id}.{ti.task_id} failed for {context['ds']} — {ti.log_url}")

default_args = {
    "retries": 3,
    "retry_delay": timedelta(minutes=5),
    "retry_exponential_backoff": True,
    "max_retry_delay": timedelta(minutes=30),
    "execution_timeout": timedelta(hours=2),  # kill tasks that hang
    "on_failure_callback": notify_failure,
}
ControlUse
PoolsCap concurrent tasks hitting a shared resource, e.g. pool="warehouse", pool_slots=1 so at most N queries run against the database
max_active_runs / max_active_tasksLimit concurrency per DAG
priority_weightWhich queued tasks get slots first
queueRoute tasks to specific Celery worker queues (e.g. GPU or high-memory workers)
Callbackson_failure_callback, on_success_callback, on_retry_callback for alerts and audit records
DeadlinesSLA misses were removed in Airflow 3; newer 3.x releases add deadline alerts. Otherwise monitor run duration or use a sensor-plus-alert pattern

12. Testing & Debugging DAGs

# tests/test_dags.py: run in CI on every pull request
from airflow.models import DagBag

def test_dags_import_cleanly():
    bag = DagBag(dag_folder="dags/", include_examples=False)
    assert bag.import_errors == {}, bag.import_errors

def test_daily_transactions_structure():
    dag = DagBag(dag_folder="dags/", include_examples=False).get_dag("daily_transactions")
    assert dag is not None
    assert set(dag.task_ids) == {"extract", "validate", "load"}
    assert dag.default_args["retries"] == 2
airflow dags list
airflow dags list-import-errors  # syntax / import problems in DAG files
airflow dags test daily_transactions 2026-10-25  # run a whole DAG locally, no scheduler
airflow tasks test daily_transactions validate 2026-10-25  # run one task, no state saved
airflow dags trigger daily_transactions -c '{"full_reload": true}'
airflow tasks clear daily_transactions -t validate -s 2026-10-20 -e 2026-10-25  # rerun a range
airflow dags pause daily_transactions / airflow dags unpause daily_transactions

13. Running Airflow in Production

OptionNotes
Amazon MWAAManaged Airflow on AWS: DAGs and requirements.txt in S3, IAM execution role, CloudWatch logs; you pick the Airflow version and environment size
Google Cloud ComposerManaged Airflow on GKE, DAGs in a GCS bucket
Astronomer (Astro)Managed Airflow from the main commercial contributor, with CI/CD and the Astro CLI
AzureWorkflow Orchestration Manager in Data Factory (managed Airflow), or self-hosted on AKS
Self-hosted on KubernetesOfficial Helm chart; KubernetesExecutor or CeleryExecutor with autoscaling workers
# MWAA deployment: everything is files in S3
aws s3 sync dags/ s3://acme-mwaa-prod/dags/ --delete
aws s3 cp requirements.txt s3://acme-mwaa-prod/requirements.txt  # pin provider versions
# then update the environment if requirements or plugins changed
aws mwaa update-environment --name acme-prod \
    --requirements-s3-object-version <version-id>

14. Best Practices

15. Airflow vs Other Orchestrators

ToolStrengthsChoose when
Apache AirflowPython DAGs, huge provider ecosystem, mature UI, runs anywhereMany systems to coordinate, complex dependencies and backfills, a team that knows Python
AWS Step FunctionsServerless, event-driven, native AWS integrations, pay per transitionAWS-only, event-driven workflows without running a scheduler
Azure Data FactoryVisual pipelines, 90+ connectors, managedAzure-centric data movement and orchestration
Databricks Jobs (Lakeflow)Built into Databricks, task DAGs, no extra infrastructureMost work already runs on Databricks
DagsterAsset-centric, strong typing and local developmentData-asset modelling and software-engineering rigour
PrefectPythonic flows, dynamic workflows, hybrid executionLightweight Python-first orchestration

16. Common Interview Questions

QuestionShort answer
What is a DAG in Airflow?A Python-defined workflow of tasks with dependencies and no cycles, plus a schedule; each execution is a DAG run for a data interval
A daily DAG with start_date 2026-10-25: when does its first run start?On 2026-10-26 at the scheduled time, because a run starts after its data interval ends; its logical date ({{ ds }}) is 2026-10-25
What is catchup, and when would you disable it?Creating runs for all intervals since start_date. Disable it (the Airflow 3 default) unless you want automatic history, and use explicit backfills instead
How do tasks share data?XComs for small values (TaskFlow returns); larger data goes to object storage or tables, passing only the path or table name
What is an executor? Which would you choose?How tasks run: Local for small installs, Celery for a steady pool of workers, Kubernetes for isolated per-task pods and elastic scale
Sensor poke vs reschedule vs deferrable?Poke holds a worker slot while waiting; reschedule frees it between checks; deferrable hands the wait to the triggerer and scales best
How do you make a DAG idempotent?Parameterise by logical date, overwrite partitions or MERGE on keys, avoid now(), make side effects safe to repeat
What are trigger rules?Conditions on upstream states that decide whether a task runs, e.g. all_done for cleanup, one_failed for alerts, none_failed_min_one_success after a branch
What is dynamic task mapping?Creating task instances at runtime from a list (e.g. one per file) with .expand()
How do you trigger a DAG when another DAG's data is ready?Data-aware scheduling with Assets (Datasets in 2.x); alternatives are TriggerDagRunOperator or an ExternalTaskSensor
Why shouldn't you put heavy code at the top level of a DAG file?DAG files are parsed repeatedly; top-level work slows the scheduler and can overload databases or APIs
What changed in Airflow 3?Task SDK and API server (workers no longer hit the DB directly), DAG versioning, assets (renamed datasets), new backfills, removal of SubDAGs, SLAs and execution_date, catchup default False, and a new React UI