Airflow Dynamic DAGs: One Ticker Shouldn't Need a PR
Airflow dynamic DAGs explained: config-driven DAG generation and task mapping with expand().
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
- ✓DAG authoring basics: dag_id, tasks, dependencies (see airflow-dags).
- ✓TaskFlow API: @task decorators and return values (see airflow-taskflow-api).
- ✓A config file or API you want to turn into pipelines.
- Dynamic DAGs come in two flavors: generated DAGs (one file per config row) and mapped tasks (expand() fans one task out per input).
- Generated DAGs: a factory function reads a config file or API and emits a DAG per entry, on every parse.
- Mapped tasks: one task definition, N task instances at run time, each with its own map_index.
- New stock ticker in a config row = zero code changes, zero PRs, zero redeploys.
- Production rule: validation and testing gate generated DAGs, or a bad config row silently kills every generated DAG.
- Airflow Variables must never be read at module scope — Variable.get() opens a metadata-DB connection on every parse cycle.
Imagine a factory that stamps out cars. You don't build a new factory for each car color — you feed the existing line a new paint spec. Generated DAGs are the factory line: one template, many configs. Task mapping is the conveyor belt inside: one machine that processes each car differently based on a tag. Either way, adding a color no longer means building a factory.
The tell-tale smell of an Airflow setup that's outgrown itself: adding a new stock ticker to the pipeline requires a code review, a deploy, and a prayer that nothing else broke.
That's what happens when DAGs are written by copy-paste. The fix is to stop writing DAGs per entity and start generating them from config — or better, map tasks over the entities at runtime.
Here are both mechanisms, when to use which, and the validation discipline that keeps generated DAGs from taking the whole scheduler down.
1. Two Kinds of Dynamic
People say "dynamic DAGs" and mean one of two completely different mechanisms.
Generated DAGs: a Python factory reads config — a file, a DB table, an API, or environment variables — and emits one DAG object per entry, every time the scheduler parses the module. The grid view shows N separate DAGs.
Mapped tasks: a single DAG whose task fans out at run time. The DAG file stays static; expand() turns one task into N task instances, each with its own map_index. The grid view shows one DAG with N task instances.
Both remove the copy-paste pattern. They differ in where the fan-out happens: at parse time vs at run time.
- Generated DAGs: N DAGs in the UI, each with its own schedule and history
- Mapped tasks: 1 DAG in the UI, N instances of one task per run
- Parse-time fan-out is visible to the scheduler immediately
- Run-time fan-out only exists while a DAG run is active
2. Config-Driven DAG Factories
The factory pattern: one module that builds DAGs from a config source. Here's the ticker version — 30 DAG files replaced by one function.
The factory reads the config, validates every row, and returns one DAG per entry. The scheduler calls the module on every parse loop, so a new ticker appears within one parse cycle — no deploy, no PR.
The critical discipline: the factory must be deterministic and fast. No API calls at module scope, no database reads. Config resolution that takes 10 seconds turns your whole scheduler into a crawl.
That rule has a name in the official docs: never read Airflow Variables at import time. Variable.get() opens a connection to the metadata DB on every parse loop, so DB-sourced config should be exported to a file in the DAG folder first. Two registration details: @dag-decorated factories auto-register each generated DAG (set auto_register=False to opt out), while plain D objects need the AG() assignment shown below. A third flavor worth knowing: generate DAG files at CI time from a template plus JSON configs, so each DAG ships as a normal, self-contained file with zero factory code in the scheduler.globals()
- No network calls, no DB reads at module scope
- Every config row validated before generation
- One bad row must fail loudly, not silently
- Keep generation under 1 second per 100 DAGs
3. Task Mapping with expand()
When every ticker runs on the same schedule and does the same work, you don't need N DAGs — you need one DAG whose task runs N times.
expand() takes the input lists and creates one task instance per row. Each instance is a first-class citizen: its own logs, its own retry state, its own entry in the grid view. The map_index distinguishes them.
Mapping shines when the fan-out is per run — a list of files that landed, a batch of IDs, a set of partitions. Dynamic task mapping has existed since Airflow 2.3, and mapped inputs can come from literal lists or from an upstream task's output.
4. map_index and Downstream Joins
Every mapped task instance carries a map_index — the position of its input in the expanded lists. It shows in the grid view, in the logs, and in the task metadata.
Downstream tasks that receive the results of a mapped task get the full mapped output — a list of N results, one per instance. If a downstream task needs to process each result, it should itself be mapped over the upstream output. Airflow preserves the correlation: downstream instance i always consumes upstream instance i's output.
The gotcha: mixing mapped and unmapped downstream tasks. An unmapped task reading a mapped output gets a list; a mapped task reading a mapped output gets element-wise pairing.
5. Validating and Testing Generated DAGs
Generated DAGs change the failure surface. A single bad config row now threatens the whole fleet — which is why validation and testing are non-negotiable.
Three gates: (1) config schema validation before generation — a required-key check fails the module loudly; (2) a unit test that loads the factory module and asserts the right DAG count; (3) a CI step that parses every generated DAG with the production DagBag and fails the build on any import error.
The test is simple and catches the real classes of failure: wrong task count, missing fields, generated DAGs that don't import.
6. The Anti-Patterns
Dynamic doesn't mean unmanageable. Three patterns turn dynamic DAGs into production incidents.
Anti-pattern one: slow generation. API calls or DB reads in the factory run on every scheduler parse. At 100 generated DAGs with a 2-second fetch each, the scheduler spends 200 seconds per loop — parsing everything else crawls.
Anti-pattern two: generation at DAG-run time. Building DAGs inside a task re-creates objects Airflow has already scheduled. If you need runtime variance, that's what mapping is for.
Anti-pattern three: unbounded generated DAG counts. A config table that grows to 5,000 tickers generates 5,000 DAGs — the UI becomes unusable and parsing saturates. At that scale, mapping is the answer, not generation.
- Generation happens on every parse loop, not once
- 1,000+ generated DAGs slow the UI and the scheduler
- If the entity count is unbounded, mapping beats generation
- A config row should add a DAG, never a dependency
The New Ticker That Needed a Code Review
- Copy-paste DAGs drift silently; the drift shows up in data, not in errors.
- Generated DAGs from a single factory eliminate whole bug classes.
- Config rows still need validation — a bad row fails at parse time.
- The factory pattern pays for itself at about 10 similar DAGs.
python -c "from dags.ticker_factory import build_ticker_dags; print(len(build_ticker_dags()))"airflow dags list | grep ticker| File | Command / Code | Purpose |
|---|---|---|
| dags | from datetime import datetime | 2. Config-Driven DAG Factories |
| dags | from datetime import datetime | 3. Task Mapping with expand() |
| dags | from datetime import datetime | 4. map_index and Downstream Joins |
| tests | from dags.ticker_factory import build_ticker_dags | 5. Validating and Testing Generated DAGs |
Key takeaways
Common mistakes to avoid
4 patternsCopy-pasting DAGs per entity instead of generating
Heavy work inside the factory at module scope
No config validation before generation
Mapped upstream feeding an unmapped downstream
Interview Questions on This Topic
Explain the difference between generated DAGs and task mapping in Airflow.
expand() to fan one task out into N task instances at run time, all under one DAG with a shared schedule. Generation scales the DAG count; mapping scales the task count.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.
That's Airflow. Mark it forged?
3 min read · try the examples if you haven't