Home DevOps Airflow Dynamic DAGs: One Ticker Shouldn't Need a PR
Advanced 3 min · August 1, 2026

Airflow Dynamic DAGs: One Ticker Shouldn't Need a PR

Airflow dynamic DAGs explained: config-driven DAG generation and task mapping with expand().

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Production
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • 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.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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.
✦ Definition~90s read
What is Airflow Dynamic DAGs?

Airflow dynamic DAGs are pipelines generated from configuration or fanned out at runtime with expand(), so adding entities stops requiring code changes.

Imagine a factory that stamps out cars.
Plain-English First

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.

Mental Model
Parse time vs run time
Generated DAGs multiply at parse time; mapped tasks multiply at run time. That difference decides everything.
  • 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
📊 Production Insight
Mixed up: teams that need per-ticker schedules use mapping, then wonder why all tickers share one schedule.
Pick the mechanism that matches the axis you're scaling.
Rule: different schedules -> generated DAGs; same schedule, different inputs -> mapping.
🎯 Key Takeaway
Generated DAGs scale the DAG count; mapping scales the task count.
Schedule per entity? Generate. Same schedule, many inputs? Map.
Two kinds of dynamic DAGs Two Kinds of Dynamic DAGs Fan-out at parse time vs run time Generated DAGs Mapped tasks Fan-out point parse time run time UI footprint N separate DAGs 1 DAG, N instances Schedules per-entity schedules one shared schedule Driver factory + config expand() inputs Copy-paste DAGs drift silently AAPL landed in the AMZN table THECODEFORGE.IO
thecodeforge.io
Airflow Dynamic Dags

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 DAG() objects need the globals() 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.

dags/ticker_factory.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
import json
from datetime import datetime
from pathlib import Path
from airflow import DAG
from airflow.decorators import task
from airflow.utils.dates import days_ago

CONFIG_PATH = Path(__file__).parent / "config" / "tickers.json"


def load_tickers():
    # Config is cheap to read; never fetch it from an API at parse time
    return json.loads(CONFIG_PATH.read_text())["tickers"]


def build_ticker_dags():
    dags = []
    for row in load_tickers():
        # Validate: a missing key fails here, at parse time, with a clear error
        ticker = row["symbol"]
        table = row["target_table"]

        dag = DAG(
            dag_id=f"ticker_{ticker.lower()}",
            start_date=datetime(2026, 1, 1),
            schedule="@daily",
            catchup=False,
        )

        @task(dag=dag)
        def fetch_and_load():
            print(f"Loading {ticker} into {table}")

        fetch_and_load()
        dags.append(dag)
    return dags


for dag in build_ticker_dags():
    globals()[dag.dag_id] = dag
⚠ Factory discipline
  • 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
📊 Production Insight
A factory that crashes on row 17 kills every generated DAG, not just row 17.
Validate the whole config first, then generate.
Rule: fail fast on the config, never half-generate the fleet.
🎯 Key Takeaway
One factory, many DAGs, zero copy-paste.
Validate first, generate after, keep parse time under a second.
Config-driven DAG factory Config-Driven DAG Factory 30 DAG files replaced by one function tickers.json — config source one row per ticker, no code Factory: validate every row missing key fails at parse time One DAG generated per entry new ticker appears in a parse cycle New ticker = one config row, no PR THECODEFORGE.IO
thecodeforge.io
Airflow Dynamic 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.

dags/ticker_mapped.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
from datetime import datetime
from airflow import DAG
from airflow.decorators import task


@task
# One instance per ticker; the input row arrives in each instance's kwargs
def load_ticker(symbol: str, target_table: str):
    print(f"Loading {symbol} into {target_table}")


with DAG(
    dag_id="market_load_daily",
    start_date=datetime(2026, 1, 1),
    schedule="@daily",
    catchup=False,
) as dag:

    load_ticker.expand(
        symbol=["AMZN", "AAPL", "GOOG", "MSFT"],
        target_table=["amzn", "aapl", "goog", "msft"],
    )
📊 Production Insight
expand() with empty input produces zero instances; downstream tasks that expect instances get an empty mapping.
Guard for empty inputs if the list can be empty.
Rule: if the input list can be empty, handle the empty-mapping case explicitly.
🎯 Key Takeaway
expand() = one task, N instances, per run.
Same schedule, many inputs: map, don't duplicate.
Task mapping with expand() Task Mapping: expand() Fans Out a Task One DAG, N instances at run time One DAG: market_load_daily single task: load_ticker map_index 0 AMZN map_index 1 AAPL map_index 2 GOOG map_index 3 MSFT Each instance is first-class own logs, retries, grid entry Run-time fan-out — not parse time THECODEFORGE.IO
thecodeforge.io
Airflow Dynamic Dags

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.

dags/ticker_downstream.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
from datetime import datetime
from airflow import DAG
from airflow.decorators import task


@task
# Each instance gets one ticker's response

def fetch_ticker(symbol: str):
    return {"symbol": symbol, "rows_loaded": len(symbol) * 1000}


@task
# Mapped over the fetch output: instance i pairs with fetch instance i

def summarize(load_result: dict):
    print(f"{load_result['symbol']}: {load_result['rows_loaded']} rows")


with DAG(
    dag_id="market_summary",
    start_date=datetime(2026, 1, 1),
    schedule="@daily",
    catchup=False,
) as dag:

    results = fetch_ticker.expand(symbol=["AMZN", "AAPL", "GOOG"])
    summarize.expand(load_result=results)
📊 Production Insight
A downstream task that isn't mapped but reads a mapped output receives ALL results as one list.
That's usually a bug dressed as a feature.
Rule: mapped upstream, mapped downstream — keep the pairing explicit.
🎯 Key Takeaway
map_index is the correlation key between mapped tasks.
Mapped upstream feeds mapped downstream, element by element.

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.

tests/test_ticker_factory.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import json
import pytest
from dags.ticker_factory import build_ticker_dags


def test_factory_generates_one_dag_per_ticker(tmp_path):
    (tmp_path / "tickers.json").write_text(
        json.dumps({"tickers": [{"symbol": "AMZN", "target_table": "amzn"}]})
    )
    dags = build_ticker_dags()
    assert len(dags) == 1
    assert dags[0].dag_id == "ticker_amzn"


def test_factory_rejects_missing_keys(tmp_path):
    (tmp_path / "tickers.json").write_text(json.dumps({"tickers": [{"symbol": "AMZN"}]}))
    with pytest.raises(KeyError):
        build_ticker_dags()
💡The 3-gate rule
Schema-validate the config, unit-test the factory, and parse every generated DAG in CI. A generated DAG is still a DAG — it deserves the same test discipline.
📊 Production Insight
Untested factories ship bugs across the whole fleet at once.
One KeyError on row 17 is an incident; a CI test would have caught it before deploy.
Rule: generation code is the highest-leverage code in the repo — test it hardest.
🎯 Key Takeaway
Generated DAGs multiply your bugs unless gated.
Schema + unit tests + CI parse = safe generation.

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.

⚠ The generation wall
  • 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
📊 Production Insight
The difference between 100 and 5,000 generated DAGs is a slow afternoon and a slow everything.
Generation scales to hundreds; mapping scales to thousands.
Rule: unbounded entity counts demand mapping, not generation.
🎯 Key Takeaway
Fast factories, runtime-safe generation, bounded counts.
Beyond the wall: map instead of generate.
● Production incidentPOST-MORTEMseverity: high

The New Ticker That Needed a Code Review

Symptom
New ticker requests landed as tickets in the data team's backlog. Meanwhile, one of the 30 DAGs silently loaded AAPL data into a table labelled AMZN — no task failed, so nobody noticed for six weeks.
Assumption
The team assumed copy-paste DAGs were cheap because each one was simple. They never counted the review time, the drift risk, or the duplicate maintenance.
Root cause
Each ticker DAG was a full copy of the previous one with two strings changed. Six weeks earlier, a copy had mixed up the ticker in the task_id and the SQL table name. Nothing validated the copy — no tests, no generated code, no config.
Fix
The team replaced 30 DAG files with one DAG factory that reads a tickers.json config and generates one DAG per entry. Adding a ticker became a config row. The mixed-up-ticker bug class disappeared because the factory uses the same code path for every entry.
Key lesson
  • 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.
Production debug guideSymptom to Action4 entries
Symptom · 01
A new config row produces no DAG in the UI
Fix
Check the scheduler logs for a parse error in the factory file. A KeyError on a missing config field fails the whole module, which kills every generated DAG — validate keys before generating.
Symptom · 02
Generated DAGs disappear after a config change
Fix
The config file failed JSON validation at parse time. Wrap config loading in try/except and log the exact error; never let one bad row take down the fleet.
Symptom · 03
Mapped tasks show the same failure across all instances
Fix
It's not the data — it's the shared callable. Test the callable with a few sample inputs in isolation before blaming map_index.
Symptom · 04
Scheduler parse time jumped after switching to generated DAGs
Fix
The factory is doing heavy work (API calls, DB reads) at module scope. Move config resolution to run time or cache it; generation must stay cheap.
★ Quick Debug Cheat Sheet — Dynamic DAGsCommands to validate generated DAGs before they touch the scheduler.
Generated DAG missing from UI
Immediate action
Test the factory module standalone
Commands
python -c "from dags.ticker_factory import build_ticker_dags; print(len(build_ticker_dags()))"
airflow dags list | grep ticker
Fix now
Fix the config row or the missing field; re-trigger DAG parsing
Bad config row fails everything+
Immediate action
Validate config before generation
Commands
python -m json.tool dags/config/tickers.json
python dags/validate_config.py
Fix now
Add a schema validator (pydantic/jsonschema) inside the factory
Mapped task instances all fail+
Immediate action
Unit-test the callable
Commands
pytest tests/test_ticker_tasks.py
airflow tasks test <dag_id> <task_id> <date>
Fix now
Fix the shared callable; mapping only multiplies its bugs
Generated DAGs vs Mapped Tasks
AspectGenerated DAGsMapped tasks
Fan-out pointParse timeRun time
UI footprintN DAGs1 DAG, N task instances
SchedulesPer-entity schedulesOne shared schedule
Scale ceiling~HundredsThousands+
Config pathFactory + config fileexpand() inputs
Failure surfaceWhole fleet at oncePer instance
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
dagsticker_factory.pyfrom datetime import datetime2. Config-Driven DAG Factories
dagsticker_mapped.pyfrom datetime import datetime3. Task Mapping with expand()
dagsticker_downstream.pyfrom datetime import datetime4. map_index and Downstream Joins
teststest_ticker_factory.pyfrom dags.ticker_factory import build_ticker_dags5. Validating and Testing Generated DAGs

Key takeaways

1
Generated DAGs scale the DAG count; mapping scales the task count.
2
A config row should add a DAG
never a code review.
3
Factories must be fast, deterministic, and validated before generation.
4
map_index is the correlation key for mapped task outputs.
5
Unbounded entity counts demand mapping, not generation.

Common mistakes to avoid

4 patterns
×

Copy-pasting DAGs per entity instead of generating

Symptom
Every new entity needs a PR and deploy; copied DAGs drift and load wrong data with no error.
Fix
Replace with a config-driven factory or task mapping — one code path for every entity.
×

Heavy work inside the factory at module scope

Symptom
Scheduler parse time explodes; every other DAG slows down as the config source grows.
Fix
Keep factory code pure: read config files, validate, generate. No network or DB calls at import time.
×

No config validation before generation

Symptom
One missing config key raises in the factory and every generated DAG disappears from the UI.
Fix
Schema-validate the whole config first, and let a bad row fail loudly at parse time — then fix the row.
×

Mapped upstream feeding an unmapped downstream

Symptom
The downstream task receives a list of all results when the code expects one result; data quietly mis-processes.
Fix
Map the downstream task over the upstream output, or handle the list explicitly.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Explain the difference between generated DAGs and task mapping in Airflo...
Q02SENIOR
Your team needs to add a new stock ticker to the pipeline without a code...
Q03JUNIOR
What is map_index and why does it matter?
Q01 of 03SENIOR

Explain the difference between generated DAGs and task mapping in Airflow.

ANSWER
Generated DAGs are produced at parse time by a factory that reads config — each entity becomes its own DAG with its own schedule, and the UI shows N DAGs. Task mapping uses 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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
When should I generate DAGs instead of using task mapping?
02
Can I mix generated DAGs and mapped tasks?
03
Why did my generated DAGs all disappear from the UI?
04
Do mapped task instances support retries independently?
05
Is there a performance cost to generated DAGs?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Everything here is grounded in real deployments.

Follow
Verified
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

3 min read · try the examples if you haven't

Previous
Airflow dbt ELT
18 / 37 · Airflow
Next
Airflow Backfill and Catchup