Apache Airflow: DAGs, Operators, Scheduling
Master Apache Airflow: DAGs, operators, sensors, XCom, scheduling, backfilling, connections, variables, and production patterns for data pipeline orchestration.
Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.
Introduction
Apache Airflow is a platform for orchestrating data pipelines through directed acyclic graphs (DAGs). You define tasks and their dependencies in Python, and Airflow schedules, executes, and monitors them. The following guide covers DAGs, operators, sensors, XCom for data passing, scheduling, backfilling, connections, variables, and production patterns.
Core Concepts
DAG: Directed Acyclic Graph — a collection of tasks with dependencies
Task: A unit of work (run a script, call an API, execute SQL)
Operator: A template for creating tasks (BashOperator, PythonOperator, etc.)
Task Instance: A specific run of a task at a specific time
DAG Run: A specific execution of a DAG at a specific time
Scheduler: Process that decides when to run tasks
Executor: Component that executes tasks (Sequential, Local, Celery, Kubernetes)
Worker: Process that runs task instances
XCom: Cross-task communication (small data passing between tasks)
Hook: Interface to external systems (S3Hook, SnowflakeHook, PostgresHook)
Connection: Stored credentials for external systems
Variable: Key-value configuration stored in metadata DB
DAG Basics
Simple DAG
# dags/simple_dag.py
from airflow import DAG
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from datetime import datetime, timedelta
default_args = {
"owner": "data-team",
"depends_on_past": False,
"retries": 2,
"retry_delay": timedelta(minutes=5),
"email_on_failure": False,
"email_on_retry": False,
}
dag = DAG(
"simple_etl",
default_args=default_args,
description="Simple ETL pipeline",
schedule="0 2 * * *", # Daily at 2 AM
start_date=datetime(2026, 1, 1),
catchup=False,
max_active_runs=1,
tags=["etl", "daily"],
)
extract = BashOperator(
task_id="extract",
bash_command="python /opt/etl/extract.py --date {{ ds }}",
dag=dag,
)
def transform(**context):
import pandas as pd
date = context["ds"]
df = pd.read_csv(f"/data/raw/{date}/orders.csv")
df["total"] = df["subtotal"] + df["tax"]
df.to_csv(f"/data/processed/{date}/orders.csv", index=False)
transform_task = PythonOperator(
task_id="transform",
python_callable=transform,
dag=dag,
)
load = BashOperator(
task_id="load",
bash_command="python /opt/etl/load.py --date {{ ds }}",
dag=dag,
)
extract >> transform_task >> load
TaskFlow API (Airflow 2.x)
# dags/taskflow_dag.py — Modern TaskFlow API
from airflow.decorators import dag, task
from datetime import datetime
@dag(
schedule="0 2 * * *",
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["etl", "taskflow"],
)
def orders_pipeline():
@task
def extract(date: str) -> dict:
import requests
resp = requests.get(f"https://api.example.com/orders?date={date}")
return resp.json()
@task
def transform(raw_data: dict) -> list[dict]:
orders = []
for order in raw_data["orders"]:
orders.append({
"order_id": order["id"],
"total": order["subtotal"] + order["tax"] + order["shipping"],
"status": order["status"],
})
return orders
@task
def load(orders: list[dict], date: str) -> None:
from airflow.providers.postgres.hooks.postgres import PostgresHook
hook = PostgresHook(postgres_conn_id="warehouse")
for order in orders:
hook.run(
"INSERT INTO orders (order_id, total, status, load_date) VALUES (%s, %s, %s, %s) "
"ON CONFLICT (order_id) DO UPDATE SET total=EXCLUDED.total, status=EXCLUDED.status",
parameters=(order["order_id"], order["total"], order["status"], date),
)
raw = extract("{{ ds }}")
cleaned = transform(raw)
load(cleaned, "{{ ds }}")
dag = orders_pipeline()
Operators
Common operators
from airflow.operators.bash import BashOperator
from airflow.operators.python import PythonOperator
from airflow.operators.email import EmailOperator
from airflow.providers.snowflake.operators.snowflake import SnowflakeOperator
from airflow.providers.amazon.aws.operators.s3 import S3CopyObjectOperator
from airflow.providers.apache.spark.operators.spark_submit import SparkSubmitOperator
from airflow.operators.dummy import DummyOperator
# Bash: run shell commands
run_script = BashOperator(
task_id="run_script",
bash_command="python /opt/jobs/process.py --date {{ ds }} --env prod",
)
# Python: call Python functions
process_data = PythonOperator(
task_id="process_data",
python_callable=my_function,
op_kwargs={"date": "{{ ds }}", "batch_size": 1000},
)
# Snowflake: execute SQL
run_sql = SnowflakeOperator(
task_id="run_sql",
sql="MERGE INTO orders USING staging_orders ON orders.id = staging_orders.id WHEN MATCHED THEN UPDATE ...",
snowflake_conn_id="snowflake_prod",
)
# Spark: submit Spark jobs
spark_job = SparkSubmitOperator(
task_id="spark_job",
conn_id="spark_default",
application="/opt/jobs/transform.py",
application_args=["--input", "s3://data/{{ ds }}/"],
conf={"spark.executor.memory": "4g", "spark.executor.cores": "2"},
)
# Email: send notifications
notify = EmailOperator(
task_id="notify",
to="data-team@company.com",
subject="Pipeline completed for {{ ds }}",
html_content="<p>Daily ETL completed successfully.</p>",
)
# Dummy: no-op for grouping
start = DummyOperator(task_id="start")
end = DummyOperator(task_id="end")
Branching
from airflow.operators.python import BranchPythonOperator
from airflow.operators.dummy import DummyOperator
def check_date(**context):
date = context["ds"]
day_of_week = datetime.strptime(date, "%Y-%m-%d").weekday()
if day_of_week == 6: # Sunday
return "weekly_aggregation"
return "daily_aggregation"
branch = BranchPythonOperator(
task_id="branch",
python_callable=check_date,
dag=dag,
)
daily = BashOperator(task_id="daily_aggregation", bash_command="...", dag=dag)
weekly = BashOperator(task_id="weekly_aggregation", bash_command="...", dag=dag)
join = DummyOperator(task_id="join", trigger_rule="none_failed", dag=dag)
branch >> [daily, weekly] >> join
Sensors
from airflow.sensors.filesystem import FileSensor
from airflow.sensors.s3 import S3KeySensor
from airflow.sensors.sql import SqlSensor
from airflow.sensors.external_task import ExternalTaskSensor
from airflow.sensors.date_time import DateTimeSensor
# Wait for a file to appear
wait_for_file = FileSensor(
task_id="wait_for_file",
filepath="/data/raw/{{ ds }}/orders.csv",
poke_interval=60, # Check every 60 seconds
timeout=3600, # Give up after 1 hour
mode="poke", # or "reschedule" to free worker slot
dag=dag,
)
# Wait for S3 object
wait_for_s3 = S3KeySensor(
task_id="wait_for_s3",
bucket_key="raw/{{ ds }}/orders.parquet",
bucket_name="my-data-bucket",
aws_conn_id="aws_default",
poke_interval=300,
timeout=7200,
dag=dag,
)
# Wait for SQL condition
wait_for_data = SqlSensor(
task_id="wait_for_data",
sql="SELECT COUNT(*) FROM staging WHERE load_date = '{{ ds }}' AND status = 'ready'",
conn_id="warehouse",
poke_interval=60,
timeout=3600,
dag=dag,
)
# Wait for another DAG to complete
wait_for_upstream = ExternalTaskSensor(
task_id="wait_for_upstream",
external_dag_id="ingestion_pipeline",
external_task_id="load_to_warehouse",
check_existence=True,
poke_interval=300,
timeout=7200,
dag=dag,
)
XCom (Cross-Task Communication)
# XCom passes small data between tasks. For large data, use external storage.
@task
def extract():
# Small data: return directly (passed via XCom)
return {"total_orders": 1500, "total_revenue": 45000.00}
@task
def validate(stats):
if stats["total_orders"] < 1:
raise ValueError("No orders found")
return stats
@task
def report(stats):
print(f"Orders: {stats['total_orders']}, Revenue: ${stats['total_revenue']}")
# For large data, use external storage (S3, GCS)
@task
def extract_large():
import pandas as pd
df = pd.read_sql("SELECT * FROM orders WHERE date = '{{ ds }}'", conn)
path = f"/tmp/orders_{{{{ ds }}}}.parquet"
df.to_parquet(path)
return path # Pass the path, not the data
@task
def transform_large(path):
import pandas as pd
df = pd.read_parquet(path)
# Transform...
return path
Scheduling and Backfilling
# Schedule presets
dag = DAG(
"scheduled_pipeline",
schedule="@daily", # Daily at midnight
# schedule="@hourly", # Every hour
# schedule="@weekly", # Weekly on Sunday
# schedule="@monthly", # Monthly on the 1st
# schedule="0 2 * * 1-5", # 2 AM on weekdays (cron)
# schedule="*/15 * * * *", # Every 15 minutes
# schedule=None, # Manual trigger only
start_date=datetime(2026, 1, 1),
catchup=True, # Backfill missing runs
max_active_runs=1, # Only one run at a time
)
Backfilling
# Backfill a date range
airflow dags backfill orders_pipeline \
--start-date 2026-01-01 \
--end-date 2026-01-31
# Backfill with specific run ID
airflow dags run orders_pipeline \
--start-date 2026-06-01
Connections and Variables
# Connections: stored in Airflow metadata DB or environment variables
# Set via UI: Admin → Connections
# Or via CLI: airflow connections add ...
from airflow.providers.postgres.hooks.postgres import PostgresHook
# Use connection in a task
def load_data(**context):
hook = PostgresHook(postgres_conn_id="warehouse_prod")
conn = hook.get_conn()
cursor = conn.cursor()
cursor.execute("INSERT INTO ... VALUES (%s)", (context["ds"],))
conn.commit()
# Variables: key-value config stored in metadata DB
from airflow.models import Variable
def get_config():
batch_size = Variable.get("batch_size", default_var=1000)
api_url = Variable.get("api_url")
return {"batch_size": int(batch_size), "api_url": api_url}
# Environment variable fallback
# AIRFLOW_VAR_API_URL=https://api.example.com
api_url = Variable.get("api_url") # Falls back to AIRFLOW_VAR_API_URL
Production Patterns
Dynamic DAG generation
# Generate DAGs from config
from airflow import DAG
from airflow.operators.python import PythonOperator
from datetime import datetime
import yaml
with open("/opt/airflow/configs/pipelines.yaml") as f:
pipelines = yaml.safe_load(f)
for pipeline in pipelines:
dag_id = f"pipeline_{pipeline['name']}"
def create_dag(pipeline):
dag = DAG(
dag_id=f"pipeline_{pipeline['name']}",
schedule=pipeline["schedule"],
start_date=datetime(2026, 1, 1),
catchup=False,
)
for step in pipeline["steps"]:
task = PythonOperator(
task_id=step["name"],
python_callable=globals()[step["function"]],
dag=dag,
)
return dag
globals()[dag_id] = create_dag(pipeline)
Error handling and retries
from airflow.operators.python import PythonOperator
from airflow.exceptions import AirflowFailException
def process_with_retry(**context):
import requests
max_attempts = 3
for attempt in range(max_attempts):
try:
resp = requests.get(f"https://api.example.com/data?date={context['ds']}")
resp.raise_for_status()
return resp.json()
except requests.RequestException as e:
if attempt == max_attempts - 1:
# Don't retry — fail immediately
raise AirflowFailException(f"API failed after {max_attempts} attempts: {e}")
# Airflow will retry based on retries in default_args
raise Exception(f"Attempt {attempt + 1} failed: {e}")
task = PythonOperator(
task_id="process_api",
python_callable=process_with_retry,
retries=3,
retry_delay=timedelta(minutes=5),
retry_exponential_backoff=True,
max_retry_delay=timedelta(minutes=30),
dag=dag,
)
SLA monitoring
from datetime import timedelta
dag = DAG(
"sla_monitored",
schedule="0 2 * * *",
start_date=datetime(2026, 1, 1),
sla_miss_callback=sla_miss_alert,
default_args={"sla": timedelta(hours=1)}, # SLA: 1 hour from scheduled time
)
def sla_miss_alert(dag, task_list, blocking_task_list, slas, blocking_tis):
"""Called when a task misses its SLA."""
for task in task_list:
print(f"SLA missed for {task.task_id} in DAG {dag.dag_id}")
Best Practices
-
For a deeper guide, see Schedule and Monitor DAGs with Apache Airflow.
-
Use TaskFlow API for new DAGs — cleaner, less boilerplate than traditional operators
-
Set
catchup=Falsefor new DAGs — avoid accidental backfills of years of data -
Use
max_active_runs=1for pipelines that shouldn’t overlap — prevents race conditions -
Use sensors with
mode="reschedule"for long waits — frees worker slots -
Pass large data via external storage (S3, GCS), not XCom — XCom is for small metadata
-
Use
retriesandretry_delayindefault_args— transient failures are common -
Use
retry_exponential_backoff=True— avoid hammering failing APIs -
Store credentials in Connections, not in code — use Airflow UI or CLI
-
Use Variables for environment-specific config — not hardcoded values
-
Tag your DAGs —
tags=["etl", "daily", "prod"]for filtering in UI -
Keep DAG files under 1000 lines — split complex pipelines into multiple DAGs
-
Use
ExternalTaskSensorfor cross-DAG dependencies — don’t duplicate upstream logic
Common Mistakes
- Using
datetime.now()asstart_date: DAGs never start because the scheduler looks for the next run after start_date. Use a fixed date. - Setting
catchup=Trueaccidentally: Airflow tries to run every missed run since start_date. Usecatchup=Falsefor new DAGs. - Passing large data through XCom: XCom stores data in the metadata DB. Use S3/GCS paths instead.
- Not setting
max_active_runs: overlapping runs cause race conditions and duplicate data. - Hardcoding credentials: use Connections and Variables. Never put passwords in DAG files.
- Using
PythonOperatorfor everything: use specialized operators (SnowflakeOperator, SparkSubmitOperator) for better logging and error handling.
FAQ
What is a DAG in Airflow?
A Directed Acyclic Graph — a collection of tasks with defined dependencies. Tasks execute in order based on the dependency graph. “Acyclic” means no circular dependencies.
What is the difference between schedule and start_date?
start_date is when the DAG’s first run is eligible to execute. schedule determines how often it runs after that. The scheduler only creates runs for dates >= start_date at the specified interval.
What is XCom?
Cross-task communication. Tasks can push and pull small data (a few KB) through XCom. For larger data, write to external storage (S3, GCS) and pass the file path via XCom.
What is the difference between poke and reschedule sensor modes?
In poke mode, the sensor occupies a worker slot while waiting. In reschedule mode, the sensor releases the worker slot between checks. Use reschedule for long waits (>1 minute) to avoid blocking workers.
Should I use TaskFlow API or traditional operators?
TaskFlow API (Airflow 2.x) is cleaner and handles XCom automatically through function return values. Use it for new DAGs. Traditional operators are still needed for specialized cases like SparkSubmitOperator or SnowflakeOperator.
Related Resources
Data Pipeline Architecture: Batch, Streaming, Lambda, Kappa
Master data pipeline architecture: batch processing, streaming, lambda and kappa patterns, ETL vs ELT, and choosing the right approach for your data workloads.
Guidedbt: Models, Tests, Macros, Materializations
Master dbt for data transformations: models, tests, macros, materializations, seeds, snapshots, Jinja templating, and production patterns for analytics engineering.
GuideData Quality Guide: Validation, Profiling, Great
Master data quality: validation frameworks, profiling, schema enforcement, anomaly detection, and monitoring with Great Expectations, Pandera, and Soda for reliable pipelines.