Data Engineering  /  Airflow

๐ŸŒฌ๏ธ Apache Airflow Guide 1 of 3 18 guides ยท updated 2026

Workflow orchestration with Airflow โ€” DAGs, operators, scheduling, executors, and the production practices that keep pipelines reliable.

Airflow XComs & Variables: Passing Data Between Tasks

Tasks in Airflow run as isolated units โ€” potentially on different machines entirely, depending on executor โ€” so they canโ€™t just share Python variables in memory. XCom (โ€œcross-communicationโ€) is the mechanism for passing small pieces of data between tasks, and Variables are Airflowโ€™s mechanism for storing configuration shared across DAGs.


XCom: Passing Data Between Tasks

Every XCom value is a key/value pair, scoped to a specific task instance, stored in the metadata database.

def extract(**context):
context["ti"].xcom_push(key="row_count", value=1543)
def report(**context):
count = context["ti"].xcom_pull(key="row_count", task_ids="extract")
print(f"Extracted {count} rows")

With the TaskFlow API (see The TaskFlow API), this is handled automatically โ€” a @task functionโ€™s return value is pushed to XCom implicitly, and passing one taskโ€™s call result as anotherโ€™s argument pulls it automatically, with no explicit xcom_push/xcom_pull calls needed.


XCom Is for Data, Not Files

This is the single most important thing to internalize about XCom: itโ€™s stored in the metadata database (by default), which means it is not designed for large payloads. Pushing a multi-megabyte DataFrame, a large JSON blob, or file contents through XCom will bloat the database, slow down the webserver rendering task details, and in the worst case cause outright failures once a database-specific size limit is hit.

# BAD โ€” pushes the entire DataFrame through XCom
@task
def extract():
df = pd.read_csv("orders.csv")
return df # multi-MB payload lands in the metadata database
# GOOD โ€” push a reference, not the data itself
@task
def extract():
df = pd.read_csv("orders.csv")
path = "/tmp/orders_processed.parquet"
df.to_parquet(path)
return path # a small string; downstream tasks read the file from this path

The correct pattern for genuinely large data is to write it somewhere durable (S3, a database table, a shared filesystem) and pass only the reference โ€” a path, a table name, an object key โ€” through XCom.


Custom XCom Backends

For teams that do need to pass larger objects routinely, Airflow supports pluggable custom XCom backends โ€” instead of storing values in the metadata database, a custom backend can transparently store the actual payload in S3/GCS and keep only a pointer in the database, giving you XComโ€™s convenient task-to-task passing semantics without the database-bloat problem. This is a deliberate architecture decision for larger deployments, not a default most small teams need immediately.


Variables: Shared Configuration

Where XCom passes data between tasks within a run, Variables store configuration shared across DAGs and runs โ€” API endpoints, feature flags, batch sizes, environment-specific settings.

from airflow.models import Variable
batch_size = Variable.get("etl_batch_size", default_var=500)
config = Variable.get("api_config", deserialize_json=True)

Managed through Admin โ†’ Variables in the UI, the CLI (airflow variables set), or bulk-imported from a JSON file โ€” and, like Connections, can be backed by an external secrets manager for sensitive values rather than stored directly in Airflowโ€™s own database.

A performance gotcha: calling Variable.get() at DAG top-level (outside a task) means it executes on every scheduler parse cycle, adding a database round-trip to every single parse โ€” exactly the same top-level-code performance trap covered in Airflow Architecture. Call Variable.get() inside task callables, not at module level, unless the value is genuinely needed to shape the DAGโ€™s structure itself (e.g., how many mapped tasks to generate).


Common Mistakes

Passing large data through XCom instead of a reference to externally-stored data โ€” far and away the most common Airflow anti-pattern related to this topic.

Calling Variable.get() at DAG top-level, adding unnecessary database load to every scheduler parse cycle across potentially hundreds of DAG files.

Storing secrets directly as plain Variables without enabling an external secrets backend โ€” Airflow does mask Variable values matching common secret-like key patterns in the UI, but the underlying value is still sitting in the metadata database unless a proper secrets backend is configured.

Frequently Asked Questions

Is there a hard size limit on XCom values? It depends on the backing database (e.g., MySQLโ€™s default TEXT/BLOB column limits), but in all cases, treat โ€œsmallโ€ (kilobytes, not megabytes) as the practical ceiling for default XCom usage.

Can multiple tasks push to the same XCom key? Yes โ€” XCom pulls default to the most recent value for a given key/task, but you can explicitly pull by task_ids (a list) to fetch values from several specific tasks distinctly.

Are Variables encrypted at rest? Values are stored using Airflowโ€™s Fernet encryption key by default in the metadata database, but for genuinely sensitive secrets, an external secrets backend remains the recommended approach over relying on Fernet alone.

Summary

XCom and Variables solve two different problems โ€” passing small values between tasks within a run, versus storing configuration shared across the whole Airflow instance โ€” and both share the same core rule: keep the actual payload small, and pass references to larger data stored elsewhere. Get this right early, because retrofitting a pipeline thatโ€™s already pushing large DataFrames through XCom is a much bigger job than designing it correctly from the start.