Tasks can run on different machines, so they cannot share Python variables. XCom (“cross communication”) stores a small value from one task in the Airflow database, where other tasks of the same run can read it. With TaskFlow you only return the value and pass it on as an argument.

Copy and paste

TaskFlow: return and pass as an argument

from airflow.sdk import task

@task
def extract() -> dict:
    return {"rows": 120, "path": "/data/sales_2026-05-15.csv"}

@task
def load(info: dict):
    print(f"Loading {info['rows']} rows from {info['path']}")

load(extract())

Pass one key on its own:

@task(multiple_outputs=True)
def stats() -> dict:
    return {"rows": 120, "path": "/data/sales_2026-05-15.csv"}

@task
def check_rows(rows: int):
    ...

check_rows(stats()["rows"])

In a template:

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

report = BashOperator(
    task_id="report",
    bash_command="echo rows={{ ti.xcom_pull(task_ids='extract')['rows'] }}",
)

In Python, from any task:

@task
def check(ti=None):
    info = ti.xcom_pull(task_ids="extract")

From a BashOperator. The last line the command prints becomes its XCom:

count = BashOperator(task_id="count_lines", bash_command="wc -l < /data/sales.csv")
# later: {{ ti.xcom_pull(task_ids='count_lines') }}

Dependencies

Passing a TaskFlow result as an argument, as in load(extract()), also sets the order: load waits for extract. Pulling a value with xcom_pull does not. Set the order yourself, or the task may run before the value exists:

extract_task = extract()
extract_task >> report

Things to know

Tested with Airflow 3.3.2.

References