Generating DAGs from config, .expand() and .partial() fan-out, and the point where dynamic generation stops being clever and starts being unmaintainable.
⏱ 5 min readTopics chapter readerLevel · Hard
01 · Start here
Start here
⏱ 4 min · Topic 1 of 8
Two different techniques are both called dynamic, and people mix them up:
Technique
Makes many
When the number is known
Generating DAGs
DAGs, from a config list.
When Airflow parses the file.
Dynamic task mapping
Task instances, from a list.
During the run.
So ask one question first: when do you know how many? If each item needs its own schedule, generate DAGs. If the count only appears during the run, map tasks.
If it is always three things, write three tasks. They are easier to read and to search for.
02 · Many DAGs
Generating many DAGs from config
⏱ 5 min · Topic 2 of 8
Suppose each shop table needs its own copy DAG, with its own schedule. A factory is a function that builds one DAG. You call it once for each table:
One DAG per tableworked example
Python
TABLES = ["orders", "customers"]
def build(table):
with DAG(
dag_id=f"copy_{table}",
schedule="@daily",
start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
catchup=False,
tags=["generated"],
) as d:
EmptyOperator(task_id="copy")
return d
for table in sorted(TABLES):
build(table)
EmptyOperator stands in for the real copy task. The tag tells people that this DAG was generated.
This file makes two DAGs, copy_orders and copy_customers. Airflow registers a DAG made with "with DAG(...)" or with @dag by itself.
A plain DAG(...) object, made without "with", was not found in our test. Old code adds it to globals() for that reason.
The factory runs on every parse. So keep the config in a file next to the DAG, not in a database or an API. A slow source makes every parse slow, and if it fails, all the DAGs disappear.
Watch out
When copy_orders fails, a search for copy_orders in the code finds nothing, because the name is built. Write the factory path in the DAG doc, so people can find it.
03 · Many tasks
Dynamic task mapping
⏱ 6 min · Topic 3 of 8
Mapping makes one task instance for each item in a list. Each instance has its own log, state and retries.
Compare one task with a loop inside. If file 2 of 3 fails, the whole task fails, and a retry loads all three again. With mapping, only that instance fails and retries.
expand() takes the values that change. partial() takes the values that stay the same:
counts is not a list. It reads each result only when you use it, so loop over it or sum it.
Before the upstream task runs, the Graph view shows a mapped task as one box. The instances appear when the list exists.
Tip
Check the list in the task that makes it. If find_files returns something wrong, it fails there, with a clear message, instead of in many mapped instances.
05 · Limits
The limits that bite
⏱ 6 min · Topic 5 of 8
A list can have at most 1024 items by default. The setting is max_map_length in the core section.
In our test, a task returned 1025 items. That task failed, not the mapped task, which became upstream_failed. Its log said only: Server returned error.
So if a list can grow, check its length in the task that makes it, and fail with a clear message.
Templates need care too. A literal list was rendered, but values from an upstream task were not:
Mapped bash_command
Output
From a literal list: ["echo day={{ ds }}"]
day=2026-01-21
From an upstream task that returns "echo from_xcom={{ ds }}"
from_xcom={{ ds }}
The second task still succeeded, with the braces in its output. So fill in dates in the task that makes the list.
Watch out
All instances can start at once. 200 instances can open 200 connections to one database. Set max_active_tis_per_dag on the task to limit how many run together.
06 · Debugging
Finding the one that failed
⏱ 4 min · Topic 6 of 8
In the Grid view, a mapped task shows its instances by number: 0, 1, 2 and so on. Numbers say little, so give each instance a name with map_index_template: