Skip to main content
Airflow beginner Lesson 2 of 9

Passing Data Between Tasks: XComs and the TaskFlow API

Move a value from one task to the next with XComs, rewrite the same DAG with the TaskFlow API, and see why XComs are the wrong place for a DataFrame.

Tasks run in separate processes, often on separate machines. XCom — cross-communication — is Airflow’s channel for passing small values between them.

The classic form

from datetime import datetime
from airflow import DAG
from airflow.operators.python import PythonOperator


def extract(**context):
    rows = [{"id": 1, "amount": 250}, {"id": 2, "amount": 180}, {"id": 3, "amount": 320}]
    print(f"extracted {len(rows)} rows")
    return {"row_count": len(rows), "path": "/tmp/extract_output.json"}


def transform(**context):
    # Pull the upstream return value by task_id.
    meta = context["ti"].xcom_pull(task_ids="extract")
    print(f"received: {meta}")

    total = 750
    context["ti"].xcom_push(key="revenue", value=total)
    return meta["row_count"] * 2


def load(**context):
    ti = context["ti"]
    doubled = ti.xcom_pull(task_ids="transform")                   # return_value
    revenue = ti.xcom_pull(task_ids="transform", key="revenue")    # explicit key
    print(f"doubled={doubled} revenue={revenue}")


with DAG(
    dag_id="xcom_classic",
    start_date=datetime(2026, 2, 1),
    schedule=None,
    catchup=False,
) as dag:
    t1 = PythonOperator(task_id="extract", python_callable=extract)
    t2 = PythonOperator(task_id="transform", python_callable=transform)
    t3 = PythonOperator(task_id="load", python_callable=load)
    t1 >> t2 >> t3
airflow tasks test xcom_classic extract   2026-02-14
airflow tasks test xcom_classic transform 2026-02-14
airflow tasks test xcom_classic load      2026-02-14
INFO - extracted 3 rows
INFO - Done. Returned value was: {'row_count': 3, 'path': '/tmp/extract_output.json'}

INFO - received: {'row_count': 3, 'path': '/tmp/extract_output.json'}
INFO - Done. Returned value was: 6

INFO - doubled=6 revenue=750

Two ways to push: returning a value (stored under the key return_value) or calling xcom_push with an explicit key. Two matching ways to pull.

The same DAG with TaskFlow

from datetime import datetime
from airflow.decorators import dag, task


@dag(
    dag_id="xcom_taskflow",
    start_date=datetime(2026, 2, 1),
    schedule=None,
    catchup=False,
)
def pipeline():

    @task
    def extract() -> dict:
        rows = [{"id": 1, "amount": 250}, {"id": 2, "amount": 180}, {"id": 3, "amount": 320}]
        print(f"extracted {len(rows)} rows")
        return {"row_count": len(rows), "path": "/tmp/extract_output.json"}

    @task
    def transform(meta: dict) -> int:
        print(f"received: {meta}")
        return meta["row_count"] * 2

    @task
    def load(doubled: int) -> None:
        print(f"doubled={doubled}")

    load(transform(extract()))


pipeline()
INFO - extracted 3 rows
INFO - received: {'row_count': 3, 'path': '/tmp/extract_output.json'}
INFO - doubled=6

Identical behaviour, and the dependency graph came from the function calls:

airflow tasks list xcom_taskflow --tree
<Task(_PythonDecoratedOperator): extract>
    <Task(_PythonDecoratedOperator): transform>
        <Task(_PythonDecoratedOperator): load>

No >> operators, no xcom_pull, no **context. Passing a task’s result as an argument both declares the dependency and moves the value. For new DAGs this is the default worth reaching for.

Mixing the two

TaskFlow tasks and classic operators compose:

from airflow.operators.bash import BashOperator

@dag(dag_id="mixed", start_date=datetime(2026, 2, 1), schedule=None, catchup=False)
def pipeline():

    @task
    def get_path() -> str:
        return "/data/2026-02-14/sales.csv"

    path = get_path()

    count_lines = BashOperator(
        task_id="count_lines",
        bash_command="wc -l < {{ ti.xcom_pull(task_ids='get_path') }}",
    )

    @task
    def report(p: str) -> None:
        print(f"processed {p}")

    path >> count_lines >> report(path)

pipeline()
INFO - Running command: ['/bin/bash', '-c', 'wc -l < /data/2026-02-14/sales.csv']
INFO - 1284
INFO - processed /data/2026-02-14/sales.csv

A BashOperator reads XComs through templating — {{ ti.xcom_pull(...) }} is rendered before the command runs.

Multiple outputs

Returning a dict and indexing it downstream creates one XCom for the whole dict. To split it:

    @task(multiple_outputs=True)
    def split() -> dict:
        return {"path": "/data/sales.csv", "rows": 1284}

    @task
    def use_path(p: str) -> None:
        print(f"path only: {p}")

    result = split()
    use_path(result["path"])
INFO - path only: /data/sales.csv

With multiple_outputs=True each key becomes its own XCom, so result["path"] pulls just that value instead of the whole dict. Without it, the subscript fails at parse time.

The size limit

XComs live in the metadata database. Try a large one:

    @task
    def too_big() -> list:
        return [{"id": i, "payload": "x" * 100} for i in range(100_000)]
[2026-02-14 11:31:07] {taskinstance.py:1938} ERROR - Task failed with exception
sqlalchemy.exc.DataError: (psycopg2.errors.ProgramLimitExceeded)
index row size 12384 exceeds btree version 4 maximum 2704 for index "xcom_pkey"

Even where it does not error outright, every XCom write and read is a database round trip. A DAG pushing megabytes through XCom will slow the scheduler for every other DAG on the cluster.

Pass a reference instead:

    @task
    def extract_to_storage() -> str:
        import json, tempfile, pathlib
        rows = [{"id": i, "amount": i * 10} for i in range(100_000)]
        path = pathlib.Path(tempfile.gettempdir()) / "extract.json"
        path.write_text(json.dumps(rows))
        print(f"wrote {len(rows)} rows to {path}")
        return str(path)                      # small string, not the data

    @task
    def process(path: str) -> int:
        import json, pathlib
        rows = json.loads(pathlib.Path(path).read_text())
        total = sum(r["amount"] for r in rows)
        print(f"read {len(rows)} rows, total {total}")
        return total
INFO - wrote 100000 rows to /tmp/extract.json
INFO - read 100000 rows, total 49999500000
INFO - Done. Returned value was: 49999500000

The rule: XComs carry pointers, storage carries data. In production that path is an S3 or GCS URI rather than local disk, since tasks may run on different machines.

Inspecting XComs

airflow xcoms list --dag-id xcom_classic 2>/dev/null || \
  airflow db shell <<'SQL'
SELECT dag_id, task_id, key, LEFT(CAST(value AS TEXT), 40) AS preview FROM xcom ORDER BY timestamp DESC LIMIT 5;
SQL
 dag_id       | task_id   | key          | preview
--------------+-----------+--------------+------------------------------------------
 xcom_classic | transform | revenue      | 750
 xcom_classic | transform | return_value | 6
 xcom_classic | extract   | return_value | {"row_count": 3, "path": "/tmp/extra

The UI shows the same under Admin → XComs. When a downstream task gets None, this is where to look — you will usually find the key is not what you expected.

Practice

1. Pull an XCom from a task that returned nothing.
    @task
    def silent() -> None:
        print("doing work")

    @task
    def reader(value) -> None:
        print(f"got: {value!r}")

    reader(silent())
INFO - doing work
INFO - got: None

None rather than an error. A task that forgets to return produces a silent None downstream, which is the most common XCom bug — assert on the value if it is required.

2. Push two values with different keys and pull only one.
context["ti"].xcom_push(key="rows", value=1284)
context["ti"].xcom_push(key="bytes", value=98304)
# downstream
ti.xcom_pull(task_ids="extract", key="rows")
1284

Without key=, xcom_pull looks for return_value and finds nothing, returning None. Explicit pushes need explicit pulls.

3. Fan out to three tasks that all read the same upstream XCom.
    data = extract()
    [transform_a(data), transform_b(data), transform_c(data)]

All three read the same XCom row — pulling does not consume it. Each is a separate database read, which is another reason to keep XComs small.

4. Return a pandas DataFrame from a TaskFlow task.
TypeError: Object of type DataFrame is not JSON serializable

The default serialiser is JSON. You could enable pickling with AIRFLOW__CORE__ENABLE_XCOM_PICKLING, but do not — it stores the whole frame in the metadata database and creates a security risk on deserialisation. Write the frame to Parquet and pass the path.

Next: scheduling, backfill, and what the logical date really means.

Frequently Asked Questions

How much data can an XCom hold?
It is stored in the metadata database, so the practical limit is small — roughly 48 KB on Postgres, and less on MySQL. XComs are for identifiers, counts, file paths, and small config. Pass a path to the data, never the data.
What is the TaskFlow API?
A decorator-based syntax where @task turns a Python function into a task and calling it wires the dependency. Return values become XComs automatically and function arguments pull them, so most DAGs read like ordinary Python.
Can a task pull an XCom from a different DAG?
Yes, with XComPull specifying dag_id and a run filter, though it is fragile — you are reaching into another DAG's internals and depending on its run timing. A Dataset or a TriggerDagRunOperator expresses the dependency properly.
Why is my XCom None?
Usually the upstream task returned nothing, or you pulled the wrong key. A PythonOperator pushes its return value under the key return_value; xcom_pull without a key looks for exactly that. If the task pushed explicitly with a custom key, you must pull with that key.