The workflow orchestrator that runs the data pipelines behind analytics, ETL, and ML — schedule, sequence, retry, and monitor tasks defined as code. You'll run Airflow locally, write DAGs, wire task dependencies, use operators and sensors, manage connections, and make pipelines idempotent and production-ready. Hands-on throughout.
The journey:
| You need | Why |
|---|---|
| Docker (Compose) | The official quick-start runs Airflow's services |
| Python 3.11+ | DAGs and tasks are Python |
| Basic scheduling concepts | Cron, dependencies, retries |
Airflow orchestrates other systems — it triggers and monitors work, it isn't a data-processing engine itself. Confirm Docker Compose: docker compose version.
Airflow is a platform to author, schedule, and monitor workflows as code. A workflow is a DAG (Directed Acyclic Graph) — tasks with dependencies, no cycles:
extract ──▶ transform ──▶ load
└────────▶ notify
Each task is a unit of work; edges define order. Airflow's scheduler runs tasks when their dependencies are met and their schedule fires, retries failures, and shows everything in a web UI (task status, logs, run history). The defining idea: pipelines are Python code, so they're versioned, reviewed, tested, and dynamic — not clicked together in a GUI.
The official Docker Compose brings up the scheduler, webserver, and metadata DB:
curl -LfO 'https://airflow.apache.org/docs/apache-airflow/stable/docker-compose.yaml'
mkdir -p dags logs plugins
echo -e "AIRFLOW_UID=$(id -u)" > .env
docker compose up airflow-init
docker compose up -d
Open http://localhost:8080 (login airflow/airflow). Drop .py files in dags/ and Airflow discovers them. The core services: the scheduler (decides what runs when), the webserver (UI), a metadata database (state), and workers/executor (run the tasks).
A DAG file defines the schedule and the tasks. Save dags/etl.py:
from airflow import DAG
from airflow.operators.bash import BashOperator
import pendulum
with DAG(
dag_id="daily_etl",
schedule="0 2 * * *", # 02:00 daily (cron)
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False, # don't backfill history on first deploy
default_args={"retries": 2},
) as dag:
extract = BashOperator(task_id="extract", bash_command="echo extracting")
transform = BashOperator(task_id="transform", bash_command="echo transforming")
load = BashOperator(task_id="load", bash_command="echo loading")
extract >> transform >> load # dependency: extract, then transform, then load
The >> operator sets order. schedule accepts cron or presets (@daily); start_date + catchup control history; default_args (retries, retry_delay) apply to every task. Airflow now runs this DAG at 02:00 daily, retrying failed tasks twice.
Modern Airflow uses the TaskFlow API — decorators that make Python functions into tasks and pass data between them naturally:
from airflow.decorators import dag, task
import pendulum
@dag(schedule="@daily", start_date=pendulum.datetime(2026, 1, 1, tz="UTC"), catchup=False)
def etl():
@task
def extract() -> dict:
return {"rows": 1000}
@task
def transform(data: dict) -> int:
return data["rows"] * 2
@task
def load(n: int):
print(f"loading {n} rows")
load(transform(extract())) # calling tasks wires the DAG
etl()
Calling one task's result into another defines the dependency and passes the value — cleaner than manual >> and XCom. Dependencies can also be explicit (a >> [b, c] for fan-out, [b, c] >> d for fan-in). TaskFlow is the recommended style for new DAGs.
Airflow's power is its operators — pre-built task types for systems you integrate with, so you don't reinvent them:
| Operator | Runs |
|---|---|
BashOperator / PythonOperator |
A command / a Python callable |
KubernetesPodOperator |
A task as a pod (isolated, any image) |
PostgresOperator / SnowflakeOperator |
SQL against a database |
S3*, HttpOperator, provider operators |
Cloud/API actions |
Operators use hooks (reusable clients for a system) that read credentials from Connections — secrets you store once in Airflow (UI or a secrets backend), never in the DAG:
from airflow.providers.postgres.operators.postgres import PostgresOperator
run_sql = PostgresOperator(task_id="agg", postgres_conn_id="warehouse", sql="INSERT INTO summary ...")
The postgres_conn_id points at a stored Connection — so DAG code has no credentials, and the same DAG runs against dev/prod by swapping the connection.
Airflow runs a DAG once per data interval (each schedule window). Two concepts trip everyone up:
catchup — if True, deploying a DAG with an old start_date runs every missed interval (a backfill). Set catchup=False unless you truly want history reprocessed.INSERT ... ON CONFLICT/MERGE, and never append blindly.@task
def load(ds=None): # ds = the run's logical date
# write to a date-partitioned target so a re-run overwrites, not duplicates
write_partition(date=ds)
The templating variable {{ ds }} (logical date) makes tasks reference their interval, not "now" — essential for correct backfills and idempotent reruns.
Sensors wait for a condition before downstream tasks run — a file to land, a partition to appear, an external job to finish:
from airflow.providers.amazon.aws.sensors.s3 import S3KeySensor
wait = S3KeySensor(task_id="wait_for_file", bucket_key="s3://data/{{ ds }}/input.csv", mode="reschedule")
Use mode="reschedule" (not the default poke) for long waits so the sensor frees its worker slot between checks instead of blocking it. For passing small values between tasks, XCom (what TaskFlow uses under the hood) stores them in the metadata DB — keep XCom payloads tiny (ids, paths), never large datasets (write those to storage and pass the path).
The executor decides where tasks run:
The scheduler stays the brain; the executor is the muscle. For Kubernetes shops, KubernetesExecutor (or the KubernetesPodOperator per task) gives per-task isolation, independent dependencies/images, and autoscaling — each task gets exactly the resources it declares.
{{ ds }}) — reruns and backfills must not duplicate.catchup=False by default; enable backfill only intentionally.reschedule mode for long waits; small XCom payloads only.KubernetesPodOperator/KubernetesExecutor for dependency and resource isolation.catchup=True unintentionally. Deploying floods the scheduler with historical runs.poke-mode sensors for long waits. They hog worker slots; use reschedule.extract >> transform >> load DAG and trigger it; read each task's logs.@dag/@task, passing a value from extract → transform → load.load write to a {{ ds }}-partitioned target and re-run the same date — confirm no duplicates.S3KeySensor in reschedule mode and a database operator using a stored Connection.Self-check:
{{ ds }} help?You now have the loop: DAG as code → schedule → dependencies → operators/connections → idempotent runs → sensors → scale with executors. That's Airflow orchestrating production pipelines.