Dynamic task mapping turns one task into as many copies as there are items in a list. The list can be written in the DAG or returned by an upstream task, so a run can create three copies today and twelve tomorrow. Each copy runs, retries and logs on its own.

Copy and paste

One task per item of a fixed list:

from airflow.sdk import task

@task
def export_region(region: str):
    print(f"Exporting {region}")

export_region.expand(region=["eu", "us", "apac"])

A list computed at run time:

@task
def list_regions() -> list[str]:
    return ["eu", "us", "apac"]      # from a query, an API, a folder...

export_region.expand(region=list_regions())

A fixed argument next to the mapped one:

@task
def export_region(region: str, run_date: str) -> int:
    ...
    return rows_exported

counts = export_region.partial(run_date="{{ ds }}").expand(region=list_regions())

Collect the results:

@task
def total(counts) -> int:
    return sum(counts)

total(counts)

Classic operators:

from airflow.providers.standard.operators.bash import BashOperator

BashOperator.partial(task_id="load_file").expand(
    bash_command=["python load.py a.csv", "python load.py b.csv"],
)

Several arguments that change together:

@task
def copy(src: str, dst: str):
    ...

copy.expand_kwargs([
    {"src": "a.csv", "dst": "archive/a.csv"},
    {"src": "b.csv", "dst": "archive/b.csv"},
])

Readable names in the UI

Mapped copies appear as 0, 1, 2. With map_index_template they show a label you choose, here the region:

from airflow.sdk import task, get_current_context

@task(map_index_template="{{ region_label }}")
def export_region(region: str):
    get_current_context()["region_label"] = region
    ...

Limits and edge cases

Why not a for loop

A for loop in the DAG file creates tasks when the file is parsed, from a list that has to be known at that moment. Every change to the list changes the shape of the DAG, and the list cannot come from data the run produces. Mapping creates the copies at run time, after the upstream task has returned the list.

Tested with Airflow 3.3.2.

References