Writing Airflow 3 DAGs
Airflow 3 DAGs are Python code that define data pipeline workflows. Every DAG must follow three principles: atomicity (each task does one thing), idempotency (same input = same output on rerun), and modularity (reusable functions and operators, DRY).
Project Structure
project/
dags/ # One .py file per DAG, filename = dag_id
include/ # Support code (NOT parsed by scheduler)
sql/
python_functions/
custom_operators/
custom_hooks/
tests/
dag_validation/ # DagBag-based structural checks
unit_tests/ # Custom code with mocked dependencies
integration_tests/ # Real external systems, no mocking
plugins/
cluster_policies/ # Environment-level enforcement (@hookimpl)
Quick Decision Guide
| Need |
Approach |
Reference |
| Standard multi-task pipeline |
TaskFlow API (@dag/@task) |
dag-authoring.md |
| Single data producer task |
Asset-oriented (@asset) |
dag-authoring.md |
| Legacy code / specific operators |
Traditional syntax (DAG class) |
dag-authoring.md |
| Variable number of task copies |
Dynamic Task Mapping (.expand()) |
dynamic-tasks.md |
| 50+ similar DAGs from config |
Dynamic DAGs (dag-factory) |
dynamic-tasks.md |
| Time-based runs |
Cron / timetables |
scheduling.md |
| Data-driven runs |
Assets |
scheduling.md |
| External event triggers |
AssetWatcher + triggers |
scheduling.md |
| Conditional execution |
Branching / trigger rules |
dependencies.md |
| Pass data between tasks |
XCom / custom backends |
data-passing.md |
| External system integration |
Operators / hooks / sensors |
operators.md |
| Credentials / secrets |
Connections / secrets backends |
connections.md |
| ETL/ELT pipeline patterns |
11 practical DAG examples |
etl-elt-patterns.md |
| Data quality checks in pipelines |
Quality gates with temp-table swap |
data-quality.md |
| Testing DAGs |
5-layer testing strategy |
testing.md |
| Production operations |
Versioning, scaling, monitoring |
production.md |
| Migrating Airflow 2 DAGs to 3 |
Breaking changes, Ruff AIR30 linting |
migration.md |
| API-triggered processing (GenAI) |
Inference execution pattern |
dag-authoring.md |
Critical Rules
These rules are non-negotiable. Violating any of them causes production failures.
1. NO Top-Level Code
DAG files are parsed by the scheduler every 30 seconds. Top-level code executes on EVERY parse.
# BAD - executes every 30 seconds during parsing
config = requests.get("https://config-api/settings").json() # API hammered
data = pd.read_csv("/data/input.csv") # I/O on every parse
now = datetime.now() # Different every parse
# GOOD - executes only when task runs
@task()
def fetch_config():
return requests.get("https://config-api/settings").json()
Allowed at top level: imports, constants, @dag/@task decorators, datetime(2024, 1, 1) literals.
2. Use Airflow Hooks, Not Raw Clients
Always use Airflow hooks (S3Hook, HttpHook, PostgresHook) instead of raw clients (boto3, requests). Hooks use Airflow connections for credential management.
# BAD - hardcoded/env credentials, no connection management
import boto3
s3 = boto3.client("s3")
# GOOD - credentials from Airflow connection
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
hook = S3Hook(aws_conn_id="aws_default")
3. Credentials at Task Time Only
Never fetch credentials during DAG definition. Use Airflow connections or secrets backends.
4. Each Task = One Atomic Unit
One task does one thing: extract OR transform OR load. Never combine. This enables partial reruns and clear observability.
5. Idempotent Tasks
Same input must produce same output. Use partitioning with Airflow context variables ({{ ds }}, {{ data_interval_start }}). Overwrite output, never append.
6. Always Set Retries
default_args = {
"retries": 3,
"retry_delay": timedelta(minutes=2),
"retry_exponential_backoff": True,
}
7. Always Set Execution Timeout
@task(execution_timeout=timedelta(minutes=30))
def my_task():
...
8. Use Deferrable Operators for Long Waits
For tasks waiting >1 minute on external systems, use deferrable operators to release worker slots.
# Set globally: AIRFLOW__OPERATORS__DEFAULT_DEFERRABLE=True
# Or per-sensor: mode="reschedule" for sensors with long waits
9. One DAG Per File
Filename should match dag_id. All support code goes in include/.
TaskFlow DAG Template
Complete example applying all critical rules:
"""ETL pipeline: API -> Transform -> S3."""
from __future__ import annotations
import json
from datetime import datetime, timedelta
from airflow.decorators import dag, task
from airflow.providers.amazon.aws.hooks.s3 import S3Hook
from airflow.providers.http.hooks.http import HttpHook
default_args = {
"retries": 3,
"retry_delay": timedelta(minutes=2),
"retry_exponential_backoff": True,
"execution_timeout": timedelta(minutes=30),
}
@dag(
dag_id="api_to_s3_etl",
start_date=datetime(2024, 1, 1),
schedule="@hourly",
catchup=False,
max_active_runs=1,
max_consecutive_failed_dag_runs=5,
default_args=default_args,
tags=["etl", "api"],
doc_md=__doc__,
)
def api_to_s3_etl():
@task()
def extract(**context) -> list[dict]:
hook = HttpHook(http_conn_id="api_default", method="GET")
response = hook.run(endpoint="/data")
return response.json()
@task()
def transform(records: list[dict]) -> list[dict]:
return [r for r in records if r.get("status") == "active"]
@task()
def load(records: list[dict], **context) -> None:
ds = context["ds"]
hook = S3Hook(aws_conn_id="aws_default")
hook.load_string(
string_data=json.dumps(records),
key=f"hourly/{ds}/data.json",
bucket_name="my-data-lake",
replace=True,
)
data = extract()
filtered = transform(data)
load(filtered)
api_to_s3_etl()
if __name__ == "__main__":
api_to_s3_etl().test()
Key patterns in this template:
@dag/@task decorators (TaskFlow API) — not with DAG() context manager
- Hooks for external systems (
HttpHook, S3Hook) — not raw requests/boto3
- Partitioned S3 key using
{{ ds }} context — idempotent
default_args with retries, backoff, timeout
max_consecutive_failed_dag_runs=5 — auto-pauses DAG after 5 consecutive failures
if __name__ block for dag.test() development testing
doc_md for UI documentation
- No top-level computation
Common Mistakes
| Mistake |
Fix |
datetime.now() as start_date |
Use fixed date: datetime(2024, 1, 1) |
requests.get() in task |
Use HttpHook(http_conn_id="...") |
boto3.client("s3") in task |
Use S3Hook(aws_conn_id="...") |
| API call at top level |
Move inside @task function |
| No retries configured |
Set retries + retry_delay in default_args |
| No execution timeout |
Set execution_timeout per task or in default_args |
| Catching all exceptions |
Let tasks fail — Airflow handles retries |
| Large data in XCom |
Use custom XCom backend (S3/GCS) — see data-passing.md |
| Local filesystem for staging |
Use cloud storage (S3/GCS) — tasks may run on different workers |
| Testing official providers |
Only test YOUR custom code — see testing.md |
pd.Timestamp.now() in transforms |
Pass timestamp from Airflow context for determinism |
Missing dag.test() block |
Add if __name__: dag.test() for IDE debugging |
Mixing with DAG() + @task |
Use @dag decorator consistently with @task |
Operator inside @asset/@task body |
Use operator as standalone task; connect via outlets |
| No data quality checks |
Use temp-table + quality gates — see data-quality.md |
Using schedule_interval= |
Renamed to schedule= in Airflow 3 — see migration.md |
| Expecting daily schedule by default |
Airflow 3 defaults to schedule=None (manual only) — set explicitly |
Using execution_date in templates |
Use logical_date — execution_date removed in Airflow 3 |
Reference Files
Deep-dive documentation organized by topic. Claude loads these on-demand:
- DAG Authoring — TaskFlow API, Traditional syntax, Asset-oriented approach, when to use each
- Scheduling — Cron, assets, event-driven, timetables, catchup, backfill
- Dependencies — chain(), >>, trigger rules, branching, task groups
- Dynamic Tasks — .expand(), .partial(), dynamic task groups, dag-factory
- Data Passing — XCom, custom backends, Object Storage backend
- Operators — Operators, sensors, deferrable operators, hooks, providers
- Connections — Connection types, secrets backends, env vars
- ETL/ELT Patterns — 11 practical DAG examples: classic ETL, ELT, incremental, asset-oriented, dynamic, fan-in, dbt, CDC, factory, ETLT hybrid, asset-sequence
- Data Quality — Quality gates, temp-table swap, SQLTableCheckOperator, stopping vs warning checks
- Testing — 5-layer testing: dag.test(), validation, unit, integration, policies
- Production — DAG versioning, bundles, scaling, callbacks, debugging
- Migration — Airflow 2→3 breaking changes, parameter renames, Ruff AIR30 linting
1---2name: airflow-dags3description: Use when creating, debugging, or configuring Apache Airflow 3 DAGs — builds data pipelines with TaskFlow API or traditional operators, configures scheduling and asset-driven triggers, wires XCom data passing, sets up sensors and deferrable operators, generates dynamic task mappings, and structures multi-layer test suites. Triggers on DAG authoring, TaskFlow API, operators, sensors, scheduling, assets, dynamic tasks, XCom, or pipeline testing.4license: MIT5---67# Writing Airflow 3 DAGs89Airflow 3 DAGs are Python code that define data pipeline workflows. Every DAG must follow three principles: **atomicity** (each task does one thing), **idempotency** (same input = same output on rerun), and **modularity** (reusable functions and operators, DRY).1011## Project Structure1213```14project/15 dags/ # One .py file per DAG, filename = dag_id16 include/ # Support code (NOT parsed by scheduler)17 sql/18 python_functions/19 custom_operators/20 custom_hooks/21 tests/22 dag_validation/ # DagBag-based structural checks23 unit_tests/ # Custom code with mocked dependencies24 integration_tests/ # Real external systems, no mocking25 plugins/26 cluster_policies/ # Environment-level enforcement (@hookimpl)27```2829## Quick Decision Guide3031| Need | Approach | Reference |32|------|----------|-----------|33| Standard multi-task pipeline | TaskFlow API (`@dag`/`@task`) | [dag-authoring.md](references/dag-authoring.md) |34| Single data producer task | Asset-oriented (`@asset`) | [dag-authoring.md](references/dag-authoring.md) |35| Legacy code / specific operators | Traditional syntax (`DAG` class) | [dag-authoring.md](references/dag-authoring.md) |36| Variable number of task copies | Dynamic Task Mapping (`.expand()`) | [dynamic-tasks.md](references/dynamic-tasks.md) |37| 50+ similar DAGs from config | Dynamic DAGs (`dag-factory`) | [dynamic-tasks.md](references/dynamic-tasks.md) |38| Time-based runs | Cron / timetables | [scheduling.md](references/scheduling.md) |39| Data-driven runs | Assets | [scheduling.md](references/scheduling.md) |40| External event triggers | AssetWatcher + triggers | [scheduling.md](references/scheduling.md) |41| Conditional execution | Branching / trigger rules | [dependencies.md](references/dependencies.md) |42| Pass data between tasks | XCom / custom backends | [data-passing.md](references/data-passing.md) |43| External system integration | Operators / hooks / sensors | [operators.md](references/operators.md) |44| Credentials / secrets | Connections / secrets backends | [connections.md](references/connections.md) |45| ETL/ELT pipeline patterns | 11 practical DAG examples | [etl-elt-patterns.md](references/etl-elt-patterns.md) |46| Data quality checks in pipelines | Quality gates with temp-table swap | [data-quality.md](references/data-quality.md) |47| Testing DAGs | 5-layer testing strategy | [testing.md](references/testing.md) |48| Production operations | Versioning, scaling, monitoring | [production.md](references/production.md) |49| Migrating Airflow 2 DAGs to 3 | Breaking changes, Ruff AIR30 linting | [migration.md](references/migration.md) |50| API-triggered processing (GenAI) | Inference execution pattern | [dag-authoring.md](references/dag-authoring.md) |5152## Critical Rules5354These rules are non-negotiable. Violating any of them causes production failures.5556### 1. NO Top-Level Code5758DAG files are parsed by the scheduler every 30 seconds. Top-level code executes on EVERY parse.5960```python61# BAD - executes every 30 seconds during parsing62config = requests.get("https://config-api/settings").json() # API hammered63data = pd.read_csv("/data/input.csv") # I/O on every parse64now = datetime.now() # Different every parse6566# GOOD - executes only when task runs67@task()68def fetch_config():69 return requests.get("https://config-api/settings").json()70```7172**Allowed at top level**: imports, constants, `@dag`/`@task` decorators, `datetime(2024, 1, 1)` literals.7374### 2. Use Airflow Hooks, Not Raw Clients7576Always use Airflow hooks (`S3Hook`, `HttpHook`, `PostgresHook`) instead of raw clients (`boto3`, `requests`). Hooks use Airflow connections for credential management.7778```python79# BAD - hardcoded/env credentials, no connection management80import boto381s3 = boto3.client("s3")8283# GOOD - credentials from Airflow connection84from airflow.providers.amazon.aws.hooks.s3 import S3Hook85hook = S3Hook(aws_conn_id="aws_default")86```8788### 3. Credentials at Task Time Only8990Never fetch credentials during DAG definition. Use Airflow connections or secrets backends.9192### 4. Each Task = One Atomic Unit9394One task does one thing: extract OR transform OR load. Never combine. This enables partial reruns and clear observability.9596### 5. Idempotent Tasks9798Same input must produce same output. Use partitioning with Airflow context variables (`{{ ds }}`, `{{ data_interval_start }}`). Overwrite output, never append.99100### 6. Always Set Retries101102```python103default_args = {104 "retries": 3,105 "retry_delay": timedelta(minutes=2),106 "retry_exponential_backoff": True,107}108```109110### 7. Always Set Execution Timeout111112```python113@task(execution_timeout=timedelta(minutes=30))114def my_task():115 ...116```117118### 8. Use Deferrable Operators for Long Waits119120For tasks waiting >1 minute on external systems, use deferrable operators to release worker slots.121122```python123# Set globally: AIRFLOW__OPERATORS__DEFAULT_DEFERRABLE=True124# Or per-sensor: mode="reschedule" for sensors with long waits125```126127### 9. One DAG Per File128129Filename should match `dag_id`. All support code goes in `include/`.130131## TaskFlow DAG Template132133Complete example applying all critical rules:134135```python136"""ETL pipeline: API -> Transform -> S3."""137from __future__ import annotations138139import json140from datetime import datetime, timedelta141142from airflow.decorators import dag, task143from airflow.providers.amazon.aws.hooks.s3 import S3Hook144from airflow.providers.http.hooks.http import HttpHook145146default_args = {147 "retries": 3,148 "retry_delay": timedelta(minutes=2),149 "retry_exponential_backoff": True,150 "execution_timeout": timedelta(minutes=30),151}152153154@dag(155 dag_id="api_to_s3_etl",156 start_date=datetime(2024, 1, 1),157 schedule="@hourly",158 catchup=False,159 max_active_runs=1,160 max_consecutive_failed_dag_runs=5,161 default_args=default_args,162 tags=["etl", "api"],163 doc_md=__doc__,164)165def api_to_s3_etl():166167 @task()168 def extract(**context) -> list[dict]:169 hook = HttpHook(http_conn_id="api_default", method="GET")170 response = hook.run(endpoint="/data")171 return response.json()172173 @task()174 def transform(records: list[dict]) -> list[dict]:175 return [r for r in records if r.get("status") == "active"]176177 @task()178 def load(records: list[dict], **context) -> None:179 ds = context["ds"]180 hook = S3Hook(aws_conn_id="aws_default")181 hook.load_string(182 string_data=json.dumps(records),183 key=f"hourly/{ds}/data.json",184 bucket_name="my-data-lake",185 replace=True,186 )187188 data = extract()189 filtered = transform(data)190 load(filtered)191192193api_to_s3_etl()194195if __name__ == "__main__":196 api_to_s3_etl().test()197```198199Key patterns in this template:200- `@dag`/`@task` decorators (TaskFlow API) — not `with DAG()` context manager201- Hooks for external systems (`HttpHook`, `S3Hook`) — not raw `requests`/`boto3`202- Partitioned S3 key using `{{ ds }}` context — idempotent203- `default_args` with retries, backoff, timeout204- `max_consecutive_failed_dag_runs=5` — auto-pauses DAG after 5 consecutive failures205- `if __name__` block for `dag.test()` development testing206- `doc_md` for UI documentation207- No top-level computation208209## Common Mistakes210211| Mistake | Fix |212|---------|-----|213| `datetime.now()` as `start_date` | Use fixed date: `datetime(2024, 1, 1)` |214| `requests.get()` in task | Use `HttpHook(http_conn_id="...")` |215| `boto3.client("s3")` in task | Use `S3Hook(aws_conn_id="...")` |216| API call at top level | Move inside `@task` function |217| No retries configured | Set `retries` + `retry_delay` in `default_args` |218| No execution timeout | Set `execution_timeout` per task or in `default_args` |219| Catching all exceptions | Let tasks fail — Airflow handles retries |220| Large data in XCom | Use custom XCom backend (S3/GCS) — see [data-passing.md](references/data-passing.md) |221| Local filesystem for staging | Use cloud storage (S3/GCS) — tasks may run on different workers |222| Testing official providers | Only test YOUR custom code — see [testing.md](references/testing.md) |223| `pd.Timestamp.now()` in transforms | Pass timestamp from Airflow context for determinism |224| Missing `dag.test()` block | Add `if __name__: dag.test()` for IDE debugging |225| Mixing `with DAG()` + `@task` | Use `@dag` decorator consistently with `@task` |226| Operator inside `@asset`/`@task` body | Use operator as standalone task; connect via `outlets` |227| No data quality checks | Use temp-table + quality gates — see [data-quality.md](references/data-quality.md) |228| Using `schedule_interval=` | Renamed to `schedule=` in Airflow 3 — see [migration.md](references/migration.md) |229| Expecting daily schedule by default | Airflow 3 defaults to `schedule=None` (manual only) — set explicitly |230| Using `execution_date` in templates | Use `logical_date` — `execution_date` removed in Airflow 3 |231232## Reference Files233234Deep-dive documentation organized by topic. Claude loads these on-demand:235236- **[DAG Authoring](references/dag-authoring.md)** — TaskFlow API, Traditional syntax, Asset-oriented approach, when to use each237- **[Scheduling](references/scheduling.md)** — Cron, assets, event-driven, timetables, catchup, backfill238- **[Dependencies](references/dependencies.md)** — chain(), >>, trigger rules, branching, task groups239- **[Dynamic Tasks](references/dynamic-tasks.md)** — .expand(), .partial(), dynamic task groups, dag-factory240- **[Data Passing](references/data-passing.md)** — XCom, custom backends, Object Storage backend241- **[Operators](references/operators.md)** — Operators, sensors, deferrable operators, hooks, providers242- **[Connections](references/connections.md)** — Connection types, secrets backends, env vars243- **[ETL/ELT Patterns](references/etl-elt-patterns.md)** — 11 practical DAG examples: classic ETL, ELT, incremental, asset-oriented, dynamic, fan-in, dbt, CDC, factory, ETLT hybrid, asset-sequence244- **[Data Quality](references/data-quality.md)** — Quality gates, temp-table swap, SQLTableCheckOperator, stopping vs warning checks245- **[Testing](references/testing.md)** — 5-layer testing: dag.test(), validation, unit, integration, policies246- **[Production](references/production.md)** — DAG versioning, bundles, scaling, callbacks, debugging247- **[Migration](references/migration.md)** — Airflow 2→3 breaking changes, parameter renames, Ruff AIR30 linting