Airflow XCom for Data Engineers
Contents:
Why interviewers ask about XCom
If you sit down for a Data Engineer screen at Stripe, Snowflake, or Databricks, expect at least one question about inter-task communication in Airflow. The interviewer is rarely testing whether you remember the exact method signature — they want to see whether you understand the metadata-database boundary: what belongs in Airflow's brain vs what belongs in object storage. That distinction is what separates juniors from staff-level candidates.
The follow-up almost always lands on size. "What happens if I push a 200MB DataFrame through XCom?" is a classic trap, and the wrong answer — "Airflow handles it" — instantly downlevels the candidate. The right answer covers the 48KB default cap, the metadata DB pressure, and the custom backend escape hatch. This post walks through all three, with code you can paste straight into a worked example during a live coding round.
What XCom actually is
XCom stands for Cross-Communication. It is a small key-value store built into Airflow for passing values between tasks inside the same DAG run (and, with a bit of effort, across DAGs). Under the hood, every push writes a row into the xcom table in Airflow's metadata database — Postgres, MySQL, or SQLite if you are still on a dev box.
That metadata-DB detail is load-bearing. It means XCom inherits all the limits of OLTP storage: row size caps, lock contention, and bloat if you never garbage-collect. Treat XCom as a registered letter, not a freight container. Pointers, IDs, row counts, and config blobs are fine. Pandas frames, NumPy arrays, fitted models, and serialized embeddings are not.
# task A pushes
def push_value(**context):
context['ti'].xcom_push(key='row_count', value=42_137)
# task B pulls
def pull_value(**context):
val = context['ti'].xcom_pull(task_ids='task_a', key='row_count')push, pull, and auto-push
The classical API uses explicit push and pull against the TaskInstance object. You can push multiple keys per task and pull from one upstream task, a list of upstreams, or all upstreams with a matching key.
# explicit push
ti.xcom_push(key='manifest_uri', value='s3://lake/raw/2026-05-20/manifest.json')
# explicit pull — single upstream
ti.xcom_pull(task_ids='extract_orders', key='manifest_uri')
# explicit pull — multiple upstreams
ti.xcom_pull(task_ids=['extract_orders', 'extract_returns'], key='manifest_uri')There is also a quieter behaviour every interviewer probes for: auto-push. If a PythonOperator (or @task) returns a value, Airflow automatically pushes it with the reserved key return_value. Downstream pulls without a key argument get that value for free.
def get_count():
return 42_137
# in a downstream task — no key argument needed
val = ti.xcom_pull(task_ids='get_count_task') # 42_137Load-bearing trick: auto-push is convenient on senior screens but a footgun in production — if a junior returns a DataFrame from a @task, the entire frame is silently JSON-serialized and parked in the metadata DB. Always wrap returns in a sanity check.
TaskFlow API in Airflow 2.x
The TaskFlow API, introduced in Airflow 2.0, makes XCom feel like normal Python. Decorators wrap the push/pull machinery and give you a typed-ish dependency graph that reads top-down.
from airflow.decorators import task, dag
from datetime import datetime
@task
def extract():
return {"row_count": 42_137, "uri": "s3://lake/raw/orders.parquet"}
@task
def transform(payload: dict):
return payload["row_count"] * 2
@dag(start_date=datetime(2026, 5, 1), schedule="@daily", catchup=False)
def my_pipeline():
raw = extract()
doubled = transform(raw)
my_pipeline()Behind the curtain, every return value still walks through the XCom table. The decorator is sugar — the constraints below still apply. If anything, TaskFlow makes the XCom-as-freight antipattern easier to commit, because the moving parts are hidden.
Size limits and the 48KB wall
The most-tested number on a DE screen is the default size cap. Airflow serializes XCom values as JSON (or pickle, if you opt in) and stores them as a single column in the metadata DB. The default max_xcom_value_size is 48 KB on Postgres backends, often lower in MySQL depending on the schema, and effectively unbounded in SQLite (but you should never run SQLite in production).
| Backend | Default cap | Typical real-world ceiling | When to switch |
|---|---|---|---|
| Postgres metadata DB | 48 KB | ~1 MB before perf drops | Daily DAGs pushing small configs |
| MySQL metadata DB | ~64 KB | ~512 KB before lock bloat | Legacy installs, avoid for new clusters |
| Custom S3-backed XCom | Unbounded | ~100s of MB practical | DataFrames, model artifacts, batch outputs |
| Custom GCS / Azure Blob | Unbounded | ~100s of MB practical | Multi-cloud lakehouse setups |
What you can push without a second thought:
- Database IDs, primary keys, batch UUIDs.
- File paths, S3 URIs, GCS object keys.
- Small configuration dicts (
{"region": "us-east-1", "limit": 100}). - Row counts, status flags, summary scalars.
What you must never push directly:
- Pandas or Polars DataFrames of any meaningful size.
- NumPy arrays, embeddings, feature vectors.
- Raw file contents, base64 blobs, parquet bytes.
- Anything above ~1 MB, even if it serializes cleanly.
Gotcha: XCom values are visible in the Airflow UI to anyone with DAG read permissions. Pushing API keys, customer PII, or auth tokens into XCom is a recurring audit finding. Use Variables, Connections, or a secrets manager — not XCom.
Custom XCom backends
When you legitimately need to pass large objects between tasks, the canonical solution is a custom XCom backend that transparently spills to object storage. The pattern: serialize big payloads to S3 (or GCS), keep only the URI in the metadata DB, and reverse the swap on deserialize.
from uuid import uuid4
import pandas as pd
from airflow.models.xcom import BaseXCom
class S3XComBackend(BaseXCom):
PREFIX = "s3://my-bucket/xcom/"
@staticmethod
def serialize_value(value):
if isinstance(value, pd.DataFrame):
key = f"{uuid4()}.parquet"
value.to_parquet(f"{S3XComBackend.PREFIX}{key}")
return BaseXCom.serialize_value(f"S3:{key}")
return BaseXCom.serialize_value(value)
@staticmethod
def deserialize_value(result):
value = BaseXCom.deserialize_value(result)
if isinstance(value, str) and value.startswith("S3:"):
key = value[3:]
return pd.read_parquet(f"{S3XComBackend.PREFIX}{key}")
return valueRegister the class via xcom_backend = my_module.S3XComBackend in airflow.cfg and every push of a DataFrame transparently round-trips through S3. The metadata DB only ever sees a short string. This pattern is the single most common follow-up question in a DE interview after the basic "what is XCom" warm-up — knowing it cold separates the candidate who has shipped Airflow from the one who has only read about it.
A few production-grade additions worth mentioning at the whiteboard:
- TTL on the bucket via S3 lifecycle rules. Don't leak storage from old runs.
- Encryption at rest with SSE-KMS, especially if your payloads carry user data.
- Naming convention that embeds
dag_id,run_id,task_id— invaluable for debugging.
Common pitfalls
The first pitfall is the obvious one and still the most common: passing a DataFrame through XCom. The symptom shows up at scale — metadata DB OOMs, DAG runs that hang on serialization, web UI pages that take 30 seconds to load because they are rendering megabytes of JSON inline. The fix is structural, not tactical: write the frame to S3 in the upstream task, push the URI, read it back downstream. A two-line change in code, a one-day investigation in production.
The second pitfall is treating XCom as a synchronization primitive. Engineers new to Airflow sometimes build flows where task B reads a flag from task A's XCom to decide whether to run, instead of using the DAG's dependency graph. This couples tasks at runtime in a way that breaks idempotency — replay the DAG and the flag may differ. Use upstream/downstream edges and branching operators (BranchPythonOperator, ShortCircuitOperator) for control flow, not XCom values.
The third pitfall is pushing secrets. Anyone with read access to the Airflow UI sees XCom values in plain text. The right home for credentials is the Connections store or a secrets backend like AWS Secrets Manager or HashiCorp Vault. If you remember nothing else from this section, remember that XCom is essentially a public bulletin board inside your engineering org.
The fourth pitfall is deep XCom chains — task A pushes to B which transforms and pushes to C which transforms and pushes to D. Each hop adds serialization cost and a failure point. When task D crashes, your debugging surface is three intermediate XCom rows, none of which has the original input. Prefer the pattern where each task reads from the source of truth (a table, a manifest, an S3 prefix) independently. XCom carries the pointer, not the payload, and not a chain of transformations.
The fifth pitfall is pushing values that influence schema or DDL. XCom is runtime state; schema is design state. If your DAG branches into different CREATE TABLE shapes based on an XCom value, you have built a deployment hazard. Schema changes belong in dbt models, Liquibase migrations, or explicit DDL DAGs, not in dynamic XCom-driven SQL.
Related reading
- Airflow on the DE interview
- Airflow sensors deep dive
- Airflow backfill explained
- dbt incremental models on the DE interview
- Airflow vs Dagster comparison
If you want to drill DE questions like this every day, NAILDD is launching with 1,500+ data engineering problems across Airflow, dbt, Spark, and warehouse internals.
FAQ
Does XCom persist between DAG runs?
Yes, by default. XCom rows live in the metadata DB until you clean them up. You can configure retention via xcom_purge_after_days or run a maintenance DAG that prunes rows older than your SLA. On clusters with hundreds of DAGs running daily, this cleanup is non-optional — without it, the XCom table grows indefinitely and starts to dominate metadata DB size, slowing the scheduler.
Can I share XCom between two different DAGs?
Yes. The standard pattern uses ExternalTaskSensor plus an explicit xcom_pull(dag_id=..., task_ids=...). It works, but it creates a hidden cross-DAG dependency that does not appear in the graph view. Most teams treat cross-DAG XCom as a code smell — the cleaner alternative is a shared table or a dataset-driven dependency using Airflow Datasets (2.4+).
What happens if I push a 100 MB DataFrame and don't have a custom backend?
On Postgres metadata DBs, the push fails immediately with a size error if max_xcom_value_size is enforced. If the cap is disabled, the row writes but the DB groans — connection pool exhaustion, lock contention on the xcom table, and a scheduler that suddenly takes seconds to schedule a task. This is the failure mode the interview question is designed to catch.
Is JSON the only serialization format?
By default, yes — Airflow uses json.dumps with a small set of extensions for datetime and pendulum. You can enable pickle via enable_xcom_pickling = True in airflow.cfg, but pickle expands the attack surface (deserialization vulnerabilities) and is discouraged in shared clusters. The custom-backend route is safer than flipping pickle on.
How does XCom interact with task retries?
Each retry attempts a fresh push. If a task pushes mid-execution and then fails, the value lingers in the metadata DB but is replaced on the next successful attempt. Idempotent push logic — same input, same output — is the assumption that makes retries safe. If you push now() or a random UUID, every retry produces a different value and downstream tasks see drift.
Is this article official Apache Airflow guidance?
No. It is a practitioner's interview-prep summary based on the Airflow 2.7+ documentation and patterns seen in production deployments. Always cross-check against the version of Airflow you are running, especially around the TaskFlow API and custom backend interfaces, which have evolved across minor versions.