The default key for Airflow xcom_pull() is return_value. When a PythonOperator callable returns a value and do_xcom_push=True, Airflow automatically publishes that result to XCom. To retrieve a value published under a custom key, specify that key explicitly.
For example, inside a running task:
ti.xcom_pull(task_ids="extract")
is equivalent to:
ti.xcom_pull(
task_ids="extract",
key="return_value",
)
This guide explains the default key, automatic return values, custom push and pull operations, TaskFlow outputs and common reasons for receiving None.
Version scope: The complete examples use Airflow 3 imports. Adaptations for Airflow 2.10 are included. Provider versions must be compatible with your Airflow installation.
What is Airflow XCom?
XCom, short for cross-communication, lets tasks exchange small values. Tasks may run on separate workers, so a local Python variable created by one task is not automatically available to another.
Useful XCom values include:
- A record count.
- A batch identifier.
- A file or table location.
- A small processing summary.
An XCom is associated with its producing task and execution context. The task ID, key and DAG run help identify the value you intend to retrieve.
The official Airflow XCom documentation (opens in a new tab) describes these concepts and the supported interfaces.
Airflow XCom default key: return_value
Two common patterns produce different keys:
| Producer behavior | XCom key | Consumer |
|---|---|---|
Python callable returns 125 | return_value | ti.xcom_pull(task_ids="extract") |
Task calls xcom_push(key="batch_id", value="batch_001") | batch_id | ti.xcom_pull(task_ids="extract", key="batch_id") |
Omitting key does not mean “retrieve any available key.” It requests return_value.
Likewise, return_value is an XCom key, not a Python variable name. Your function can return a variable named count, result or anything else.
The same default is documented in Airflow 2.10.5 (opens in a new tab).
Does PythonOperator automatically push its return value?
Yes. With do_xcom_push=True, PythonOperator publishes its callable’s returned result under return_value. Automatic pushing is enabled by default, but the example below sets it explicitly.
Returning and printing are different:
def extract_count():
print("Extraction complete")
return 125
Here, the function returns 125. The printed message is log output, not the callable’s returned result.
If the function has no return statement, Python returns None; do not expect a record count to appear automatically.
Example 1: PythonOperator XCom return value
This complete DAG publishes a count and retrieves it in a downstream task.
import logging
from pendulum import datetime
from airflow.sdk import DAG, get_current_context
from airflow.providers.standard.operators.python import PythonOperator
def extract_count():
return 125
def report_count():
ti = get_current_context()["ti"]
count = ti.xcom_pull(
task_ids="extract",
key="return_value",
)
if count is None:
raise ValueError("Missing count from task 'extract'")
logging.info("Records extracted: %s", count)
with DAG(
dag_id="xcom_return_value_example",
start_date=datetime(2026, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
) as dag:
extract = PythonOperator(
task_id="extract",
python_callable=extract_count,
do_xcom_push=True,
)
report = PythonOperator(
task_id="report",
python_callable=report_count,
)
extract >> report
Expected message in the report task log:
Records extracted: 125
Three details matter:
task_ids="extract"matches the producer’s task ID, rather than its function name.python_callable=extract_countpasses the function without calling it during DAG parsing.extract >> reportestablishes the dependency.
A manual xcom_pull() call does not establish task order.
See the official PythonOperator documentation (opens in a new tab) for callable arguments and context handling.
Airflow 2.10 imports
For Airflow 2.10, replace the Airflow imports above with:
from airflow import DAG
from airflow.operators.python import (
PythonOperator,
get_current_context,
)
Keep the rest of this example unchanged. It does not require the legacy provide_context=True argument.
Call get_current_context() inside the executing callable, not at module level. See the Airflow 2.10.5 PythonOperator documentation (opens in a new tab).
Example 2: Push and pull a custom XCom key
Use a custom key when you want to publish a specifically named value.
This complete Airflow 3 DAG demonstrates the pattern:
import logging
from pendulum import datetime
from airflow.sdk import dag, task, get_current_context
@dag(
dag_id="xcom_custom_key_example",
start_date=datetime(2026, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
)
def xcom_custom_key_example():
@task
def publish_metadata():
ti = get_current_context()["ti"]
ti.xcom_push(
key="batch_id",
value="batch_001",
)
@task
def read_metadata():
ti = get_current_context()["ti"]
batch_id = ti.xcom_pull(
task_ids="publish_metadata",
key="batch_id",
)
if batch_id is None:
raise ValueError("Missing required batch_id")
logging.info("Processing batch: %s", batch_id)
publish_metadata() >> read_metadata()
xcom_custom_key_example()
Expected message:
Processing batch: batch_001
The producer and consumer use the same key: batch_id.
For Airflow 2.10, replace the SDK import with:
from airflow.decorators import dag, task
from airflow.operators.python import get_current_context
Example 3: Pass outputs with TaskFlow
For straightforward Python task connections, TaskFlow handles XCom exchange and dependency wiring.
import logging
from pendulum import datetime
from airflow.sdk import dag, task
@dag(
dag_id="xcom_taskflow_example",
start_date=datetime(2026, 1, 1, tz="UTC"),
schedule=None,
catchup=False,
)
def xcom_taskflow_example():
@task
def extract():
return 125
@task
def report(count):
logging.info("Records extracted: %s", count)
report(extract())
xcom_taskflow_example()
When Airflow constructs the DAG, extract() creates a task output reference. It does not immediately execute the extraction function.
Passing that reference to report() establishes the dependency. During execution, Airflow resolves the output value.
For Airflow 2.10, use:
from airflow.decorators import dag, task
See the official TaskFlow tutorials for Airflow 3 (opens in a new tab) and Airflow 2.10.5 (opens in a new tab).
How to return multiple named outputs
With TaskFlow, multiple_outputs=True exposes dictionary entries as individual outputs.
Place this fragment inside a TaskFlow DAG:
@task(multiple_outputs=True)
def summarize():
return {
"accepted_count": 125,
"rejected_count": 3,
}
@task
def report_summary(accepted_count, rejected_count):
print(
f"Accepted: {accepted_count}; "
f"rejected: {rejected_count}"
)
summary = summarize()
report_summary(
summary["accepted_count"],
summary["rejected_count"],
)
Expected message:
Accepted: 125; rejected: 3
Returning a dictionary alone does not always mean its entries are separate XCom keys. Setting multiple_outputs=True makes that intention explicit. TaskFlow can also infer multiple outputs from certain return type annotations.
What does SQLExecuteQueryOperator return through XCom?
SQLExecuteQueryOperator does not necessarily return a single scalar value.
Its result depends on the database hook, result handler, output processing and statement settings. The default handler fetches rows; setting handler=None prevents returning fetched results.
For a single query such as:
SELECT COUNT(*) FROM orders;
a common fetched result is:
[(125,)]
After XCom serialization, the outer and inner sequences may not retain exactly the same Python container types. Do not assume the output is simply 125.
For that specific one-row, one-column shape, a consumer can validate and extract it:
rows = ti.xcom_pull(task_ids="count_orders")
if rows is None or len(rows) != 1 or len(rows[0]) != 1:
raise ValueError("Expected one row containing one value")
count = rows[0][0]
This assumes the operator publishes its result with XCom pushing enabled. Multiple SQL statements can introduce another result level, so inspect the actual output before applying this extraction pattern.
Consult the SQL operator guide (opens in a new tab) and SQLExecuteQueryOperator API reference (opens in a new tab) for your installed provider version.
Why does ti.xcom_pull() return None?
Check the producer before changing the consumer.
| Check | What to verify |
|---|---|
| Task execution | Did the producer succeed and publish the value? |
| Task ID | Does the pull use the actual producer ID? |
| Key | Was the result stored under return_value or another key? |
| Return statement | Does the Python callable return the intended value? |
| Configuration | Is automatic XCom pushing enabled? |
| Dependency | Does the producer complete before the consumer executes? |
| DAG run | Are you inspecting the intended execution? |
| Retry | Did a failed attempt publish a value that was subsequently cleared? |
Airflow documents that XComs from unsuccessful task attempts are cleared on retries. Do not use them as durable checkpoints across failed attempts.
Check TaskGroup prefixes
By default, TaskGroup membership prefixes a task’s ID. A task named extract inside a group named ingestion has an ID such as:
ingestion.extract
A manual pull should use that actual ID:
value = ti.xcom_pull(
task_ids="ingestion.extract",
key="return_value",
)
TaskGroup prefixing can be configured differently. Check the actual task ID rather than guessing it. See Airflow TaskGroups (opens in a new tab).
Distinguish missing values from valid empty values
Use:
if count is None:
raise ValueError("Required count is missing")
rather than:
if not count:
raise ValueError("Required count is missing")
The second condition also rejects 0. Depending on your workflow, an empty list or False may also be a legitimate result.
Always specify the producing task
Airflow 3’s documented behavior differs from Airflow 2 when task_ids is omitted. For pulls from another task, specify the ID explicitly.
The examples here target ordinary, unmapped tasks within one DAG run. Dynamic task mapping and cross-DAG communication require additional execution-context decisions.
What should you store in XCom?
Our recommended pattern is to return a small description of an output:
{
"file_uri": "gs://example-bucket/exports/batch_001.parquet",
"record_count": 125000,
"batch_id": "batch_001",
}
The producer must actually create the file, and the consumer needs permission to read it.
Avoid passing full DataFrames or large query results through the default backend. Store the dataset externally and pass its reference.
The default XCom backend stores values in Airflow’s metadata database. An object storage XCom backend (opens in a new tab) is available when configured, but it does not replace thoughtful task-output design.
Frequently asked questions
Is ti.xcom_pull() different from task_instance.xcom_pull()?
ti and task_instance are commonly used names for the task-instance object in execution context. The variable name does not change the default key.
Should I use key=None to retrieve return_value?
Use key="return_value" explicitly, or omit the key. That expresses the intended lookup clearly.
Can I use XCom as shared configuration?
Use XCom for task-execution outputs. Airflow Variables serve shared configuration needs rather than representing a particular task’s result.
Should I query the metadata database to retrieve XComs?
For Airflow 3 task code, use supported task-context and SDK interfaces. Direct metadata-database access is not a supported replacement for XCom operations. See the Airflow 3 public interface (opens in a new tab).
Verify your DAG before deploying
Save each complete DAG in a separate Python file, confirm it loads without import errors and trigger it manually.
For each example, verify:
- The producer completes successfully.
- The expected XCom key and value appear for that run.
- The consumer receives the expected value.
- Missing values produce a useful error.
- Valid edge cases, including a zero count, behave correctly.
For a Python workflow, TaskFlow output references usually make the connection between tasks easy to follow. When using explicit push and pull calls, keep the producing task, key and dependency visible.
Building pipelines on Airflow? See how we build data platforms.
