Every Airflow DAG works in a tutorial. Production is where they fall apart — and usually not because the code is wrong, but because the design assumed the world would never retry, never flake, and never run 365 missed schedules at once.
This is a practical set of patterns for DAGs that survive contact with production: idempotency, atomic tasks, dynamic task mapping versus dynamic DAGs, backfills, sensors (and their deadlocks), secrets, retries, trigger rules, and testing. No hype, no "10x your pipelines" — just the stuff that stops the 2 a.m. page.
1. Idempotency is the whole game
If a task is not safe to re-run, it is not production-safe. Period. Airflow will re-run tasks — after retries, after manual clears, after a worker dies mid-task. Your DAG must produce the same result no matter how many times it executes.
Concretely: prefer MERGE / INSERT OVERWRITE over INSERT, and partition targets by execution date so a re-run replaces exactly the same slice:
# Good: idempotent partition overwrite
df.write.format("delta").mode("overwrite") \
.option("replaceWhere", f"date = '{ds}'") \
.saveAsTable("gold.daily_metrics")
# Bad: append creates duplicates on re-run
df.write.format("delta").mode("append").saveAsTable("gold.daily_metrics")
2. Atomic tasks, small tasks
Each task should do one thing, and its state should be either "not started" or "fully complete" — never partially complete. Write to a staging location and rename on success, use transactions where possible, and write checkpoint files so a retry resumes safely instead of restarting blind.
Then split the monolith. Separate extract / transform / quality / load tasks give you granular retry (only re-run what failed), clear observability (which step is slow?), and parallel execution. The mega-task that does everything is the anti-pattern that turns a one-line failure into a whole-pipeline replay.
3. Nothing at top level
The scheduler parses every DAG file roughly every 30 seconds. Any code outside the DAG context manager or task definitions runs on every parse. A db.execute("SELECT COUNT(*) FROM orders") at module level means one query every 30 seconds forever, and a slow file sensor at import time stalls the whole scheduler.
Put it in a task:
# BAD — this query runs every 30 seconds during scheduling
result = db.execute("SELECT COUNT(*) FROM orders")
# GOOD — query runs only when the task executes
def get_count(**context):
return db.execute("SELECT COUNT(*) FROM orders").scalar()
4. Backfills are explicit, not accidental
Set catchup=False by default. Forgetting this is the #1 cause of "why did 365 DAG runs just fire at once?" when you deploy a DAG with a start_date in the past.
When you do want history, make backfill a deliberate, parameterized DAG — date-range chunking, parallelism control, progress tracking — instead of relying on catch-up behavior you didn't ask for.
5. XCom is metadata, not a data bus
XCom values live in the metadata database, and most setups have a practical limit around 48 KB per value. Pushing DataFrames or full query results through XCom bloats the DB and slows the UI.
Pass small metadata — file paths, row counts, status strings. For large data, write to object storage (S3, GCS, ADLS) and push only the path. If your team routinely passes large payloads between tasks, look at a custom XCom backend that offloads storage to object storage instead of the DB.
6. Dynamic task mapping, not parse-time generation
Dynamic task mapping (Airflow 2.3+) replaces the old pattern of generating tasks at parse time with runtime-determined parallelism. Map over a list with expand(), pin static args with partial(), and pass dicts with expand_kwargs():
process.partial(date="{{ ds }}", env="production").expand(table=tables)
Cap it: max_map_length in airflow.cfg (default 1024), and monitor mapped task counts — each mapped instance consumes a worker slot, and unbounded .expand() can exhaust the pool.
If you still need DAG factories driven by YAML config, keep them small — under 100 DAGs, because each adds scheduler overhead — and use closures to bind loop variables, or every task gets the last value due to late binding.
7. Sensors: reschedule, or you'll deadlock
ExternalTaskSensor is how you wait for another DAG's task. Two rules keep you out of the worst failure modes:
-
Prefer
mode="reschedule"for long waits.mode="poke"holds a worker slot the entire time — pool exhaustion waiting to happen. - Map your cross-DAG dependencies before writing sensors. If DAG A waits for DAG B and DAG B waits for DAG A, both hang forever. That's a deadlock you will not notice until nothing runs.
Also watch schedule mismatch: if DAG A is @hourly and DAG B is @daily, the sensor's execution_delta must account for the difference, or it will wait for a run that doesn't exist.
8. Secrets: connections and variables, not env vars
Use Airflow Connections rather than raw env vars — encrypted at rest, auditable, UI-editable. Keep one connection per environment (aws_prod, aws_staging, aws_dev), and for anything sensitive, store them in a secrets backend like Vault or AWS Secrets Manager rather than the metadata DB where admins can see them.
For variables: Variable.get("environment", default_var="dev") won't crash when missing, and batch them into one JSON variable instead of scattering Variable.get() calls at module level — every one of those is a DB query during DAG parsing.
9. Retries and trigger rules
Set retries per task — @task(retries=3, retry_delay=timedelta(minutes=5)) — and let tasks fail loudly instead of swallowing exceptions. Catching everything "just in case" hides the failure from Airflow's retry and alerting machinery, which is the entire point of the platform.
Trigger rules are the less obvious lever. The default all_success is right for strict pipelines, but fan-in joins often want all_done (run regardless of upstream success/failure, e.g. for cleanup) or none_failed_min_one_success for a branch that tolerates partial failure. A terminal task with trigger_rule="none_failed_min_one_success" is how you get accurate DAG-level status instead of a forever-failed run because one optional branch broke.
10. Test DAGs at four levels
-
Import test — load with
DagBagand assert zeroimport_errors. Catches syntax errors before deploy. -
Structure validation — every DAG has an owner, tags, description, and
catchup=False. - Callable unit tests — test your Python functions independently of Airflow.
-
Integration with
dag.test()(Airflow 2.5+) — run an entire DAG in a single process with a fixedexecution_date.
This four-level ladder is cheap to maintain and turns "deploy and pray" into "deploy and know."
The patterns above are the same ones that ship in the Airflow DAG Templates pack from DataStack Pro: 14 production-shaped DAGs covering ETL, data quality, ML pipelines, warehouse loading, CDC streaming, database replication, SLA monitoring, and dynamic task mapping — plus custom operators (Spark submit, data quality, Databricks notebook, Delta sensor), an extended S3 sensor, and a 450+ line best-practices guide covering TaskFlow, dynamic mapping, SLAs, and testing. Pair it with the Data Pipeline Testing Kit if you need PySpark unit and integration test scaffolding.
The full DataStack Pro collection covers the rest of the modern data stack: Spark ETL frameworks, Delta Lake patterns, data quality engines, CDC replication, schema evolution, and more.
Top comments (0)