Home DevOps Airflow XComs: The 10MB Payload That Froze the Database
Intermediate 3 min · September 04, 2026
Airflow XComs for Data Passing

Airflow XComs: The 10MB Payload That Froze the Database

Airflow XComs live in the metadata DB, so a 10MB payload froze scheduling fleet-wide.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 25 min
  • TaskFlow basics: @dag and @task wiring
  • Know where the metadata DB fits in Airflow
  • An S3 or GCS bucket for the reference pattern
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • XComs are small task-to-task values pushed by returns and pulled by key, persisted in the metadata DB
  • TaskFlow automates the wiring: returns become XCom entries and XComArgs chain dependencies implicitly
  • One 10MB JSON per run bloats scheduler tables into gigabytes and slows every metadata read fleet-wide
  • Production rule: pass references not data, storing artifacts in object storage and returning only URIs
  • Keep secrets out of XCom; credentials belong in connections and secrets backends
✦ Definition~90s read
What is Airflow XComs for Data Passing?

XComs are small keyed values tasks exchange through Airflow's metadata database, carrying IDs, URIs, and flags between steps of a run.

XComs are sticky notes passed between workers on a shared office board: perfect for short messages like file locations and approval flags, but if someone glues a whole encyclopedia to the board every hour, nobody can find anything and the board collapses.
Plain-English First

XComs are sticky notes passed between workers on a shared office board: perfect for short messages like file locations and approval flags, but if someone glues a whole encyclopedia to the board every hour, nobody can find anything and the board collapses.

A task returned a 10MB JSON dict and everything slowed down. The scheduler lagged, the UI timed out, and the metadata DB grew gigabytes in days. The pipeline logic was correct. The channel was wrong.

XComs live in the metadata database, which the scheduler reads constantly. You'll learn why small values fly and large ones freeze the fleet.

We'll cover push and pull patterns, the size trap, and reference passing. Pass pointers, not payloads.

What XComs Are and Where They Live

XCom stands for cross-communication: keyed values tasks exchange through the metadata DB. Each entry carries dag_id, task_id, run_id, key, and a serialized value. Small by design.

Classic tasks push and pull explicitly with xcom_push and xcom_pull. TaskFlow hides the plumbing: returning a value pushes it, and passing task outputs as arguments pulls it. Same store, friendlier syntax.

Treat XCom as a message bus for control data. IDs, URIs, row counts, flags, and tiny dicts belong here. Anything you would hesitate to put in a URL probably does not belong.

Limits come from the metadata DB: Postgres ~1GB, SQLite ~2GB, MySQL ~64KB — and MySQL's 64KB bites fast. Default serialization covers JSON, plus pandas DataFrames (2.6+), Delta and Iceberg tables (2.8+). You'll view rows under Admin > XComs and pull cross-DAG with dag_id plus run_id for TriggerDagRunOperator chains.

📊 Production Insight
Control plane versus data plane decides everything.
Small messages keep scheduler reads fast.
Rule: hesitate at URL size, reroute past KBs.
🎯 Key Takeaway
Keyed messages in the metadata DB, not a data lake.
TaskFlow hides plumbing; the store stays the same.
URL-sized values belong; payloads do not.

Implicit Push and Pull via TaskFlow

Call a @task function and you get an XComArg, not a result. Passing that arg into another task declares both the dependency and the data flow. The scheduler wires the edge; runtime resolves the value.

Returns push automatically under the return_value key. Parameters pull automatically from their upstream args. Explicit xcom_pull remains available for fan-in and dynamic patterns.

This magic stays debuggable. The Graph view shows the edges, and each task instance's XCom tab shows pushed keys. Implicit wiring still leaves visible tracks.

Need fan-out keys? Use @task(do_xcom_push=True, multiple_outputs=True) returning {"key1": ..., "key2": ...} — each key becomes its own XCom you'd pull by key. In templates you'd write SELECT * FROM {{ ti.xcom_pull(task_ids='foo', key='table_name') }}. Note: xcom_pull() with no task_ids only reads the current task in 3.x, and XComs clear on every retry so they can't carry state across tries.

dags/order_refs.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import pendulum
from airflow.sdk import dag, task

@dag(
    schedule="@daily",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    tags=["orders"],
)
def order_refs():
    @task
    def extract() -> str:
        # small reference, not the data itself
        return "s3://forge-staging/orders/2026-09-03.json"

    @task
    def transform(uri: str) -> str:
        print(f"transforming {uri}")
        return "s3://forge-staging/orders/2026-09-03-clean.json"

    @task
    def load(uri: str) -> None:
        print(f"loading {uri}")

    load(transform(extract()))

order_refs()
📊 Production Insight
Implicit wiring cuts boilerplate without hiding flow.
XCom tabs prove what actually moved.
Rule: check pushed keys when Nones appear.
🎯 Key Takeaway
Function calls wire edges; returns carry values.
XComArg is a promise, resolved at runtime.
Magic with visible tracks in UI.

xcom_pull Patterns: Single, List, by task_id

Pull one value with xcom_pull(task_ids="extract", key="return_value"). Pull fan-in with a list of task_ids. Pull from mapped tasks with map_index. Each form names its source explicitly.

Prefer explicit task_ids over ambient pulls. Unscoped xcom_pull() grabs from the current context's upstream in ways refactors silently rewire. Named sources survive renames with grep-able tracks.

Document keys like API contracts. A producer's return dict is a schema; changing a key breaks consumers exactly like renaming a JSON field.

⚠ The Kilobyte Smell Test
Every kilobyte in XCom is read back by scheduler queries that never needed it. Returns over a few KB belong in object storage with only the URI returned.
📊 Production Insight
Wrong-key pulls return None, not errors.
Explicit task_ids make flows grep-able.
Rule: test producer key sets in CI.
🎯 Key Takeaway
Name sources explicitly; ambient pulls rot silently.
Return dicts are schemas with consumers.
Keys deserve contracts and tests.

The Size Trap and DB Bloat

The size trap compounds. One 10MB value times three tasks times daily runs times retained history equals gigabytes in weeks. The xcom table becomes the largest in the metadata DB.

Scheduler queries slow first. Listing runs, rendering Grid, and resolving dependencies all read tables sharing buffers with XCom bloat. Unrelated DAGs stall behind one pipeline's payloads.

Detect with table-size queries and DB growth alerts. The fattest task_ids name the refactor targets. Trend the metric weekly; bloat never announces itself.

For bigger payloads switch backends: set xcom_backend to the object-storage backend (S3/GCS) or a custom BaseXCom subclass with serialize/deserialize plus purge. Verify inside containers with from airflow.sdk.execution_time.xcom import XCom; print(XCom.__name__). CLI helps too: airflow tasks xcom_pull -d <dag> -t <task>.

xcom_bloat.sqlSQL
1
2
3
4
5
6
7
8
9
# find the fattest XComs before they freeze the scheduler
# (Postgres metadata DB)
SELECT dag_id, task_id,
       pg_size_pretty(SUM(pg_column_size(value))) AS total
FROM xcom
WHERE execution_date > NOW() - INTERVAL '7 days'
GROUP BY dag_id, task_id
ORDER BY SUM(pg_column_size(value)) DESC
LIMIT 10;
📊 Production Insight
Bloat compounds silently across retained runs.
Size queries name the worst offenders.
Rule: alert on xcom table growth weekly.
🎯 Key Takeaway
Megabytes times history equals gigabytes fast.
Scheduler reads pay for data they never use.
Trend xcom size like latency.

The Rule: Pass References, Not Data

Reference passing is the whole discipline. Producers write bytes to object storage at versioned paths and return URI strings. Consumers stream by URI. The DB carries sentences, not encyclopedias.

Version the paths with dates or run_ids so reruns and backfills never collide. Add lifecycle rules to expire staging prefixes. Cheap storage with cleanup beats clever in-DB tricks.

Sensors can join the pattern. An XCom-pushed URI becomes the file a downstream sensor waits on, or the partition a quality check counts. Pointers compose; payloads do not.

dags/order_uris.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
import pendulum
from airflow.sdk import dag, task
from airflow.providers.amazon.aws.hooks.s3 import S3Hook

@dag(
    schedule="@daily",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    tags=["orders"],
)
def order_uris():
    @task
    def extract() -> str:
        hook = S3Hook(aws_conn_id="forge_storage")
        payload = b'{"orders": 4120}'
        key = "staging/orders/2026-09-03.json"
        hook.load_bytes(payload, key=key, bucket_name="forge-staging")
        return f"s3://forge-staging/{key}"

    @task
    def load(uri: str) -> int:
        print(f"loading from {uri}")
        return 4120

    load(extract())

order_uris()
📊 Production Insight
Reference DAGs scale to gigabytes effortlessly.
Lifecycle rules prevent bucket sprawl.
Rule: every artifact path carries its date.
🎯 Key Takeaway
URIs in XCom, bytes in buckets, always.
Versioned paths keep reruns collision-free.
Pointers compose; payloads clog.

XCom-Enabled Sensors and When XComs Expire

XCom entries live as long as their runs remain in history. Cleanup purges old runs and their messages together. Nothing expires independently on a timer.

That makes XCom run-scoped by nature. Values needed next quarter belong in tables or buckets with real retention policies. Re-derive or re-read rather than digging through old runs.

Sensors with XCom awareness fit short handoffs: a sensor reads a pushed URI or flag within the same run. Cross-run signaling belongs to assets and datasets, covered later in this course.

📊 Production Insight
Old-run archaeology is not a data strategy.
Assets handle cross-run signaling.
Rule: same-run pointers only.
🎯 Key Takeaway
Run-scoped messages die with run history.
Quarterly needs belong in tables, not XCom.
Short handoffs yes, archives never.
● Production incidentPOST-MORTEMseverity: high

The 10MB Payload That Froze the DB

Symptom
Scheduler lag grew from 4s to 47s over 14 days while the metadata DB disk climbed 2.7GB per day. Listing XComs timed out after 60s and 5 unrelated DAGs started 10 minutes late on idle workers. The warehouse was quiet at 12% CPU; the 38GB xcom table was the bottleneck nobody'd queried.
Assumption
The author assumed XCom was a general data channel and that returning the dict was idiomatic TaskFlow because it worked on 50-row dev samples. They believed the Postgres metadata DB could absorb whatever tasks produce, like a warehouse table. Size limits felt theoretical, so they didn't test with a full 41,000-row extract.
Root cause
Default BaseXCom serializes every return into the metadata DB that the scheduler, parser, and UI query constantly. A 10MB value times 3 tasks times 214 runs plus retries compounded into 38GB of control-plane bloat. Each scheduler read paid deserialization and buffer costs for payload data it never needed, dragging parses and UI queries to a crawl.
Fix
They switched to reference passing: extract uploads to s3://forge-staging/orders/2026-09-03.json via S3Hook and returns only the 58-char URI string. Consumers download by URI, and they found offenders first with SELECT dag_id, task_id FROM xcom grouping by pg_column_size(value). DB growth fell to 40MB per day and Grid loads dropped from 60s to 3s.
Key lesson
  • XComs are a control plane in the metadata DB; they can't carry 10MB data-plane payloads.
  • Pass references not data: 58-char URIs in XCom, megabytes in object storage.
  • Lint payload sizes in CI because 2.7GB per day of bloat compounds silently.
Production debug guideXCom failures are channel failures: wrong size, wrong key, wrong place.4 entries
Symptom · 01
Metadata DB grows gigabytes with XCom bloat
Fix
Query the xcom table size per DAG and find offending task_ids. Rewrite producers to upload to object storage and return URIs, or set xcom_backend to S3/GCS. Check Admin > XComs, then purge historic rows with DB cleanup.
Symptom · 02
Downstream task receives None with no error
Fix
Open the task's XCom tab in the UI and compare pushed keys against the consumer's pull key. Fix the key or task_id, then add a unit test asserting the producer's return dict keys.
Symptom · 03
File-path XCom works locally but fails on Celery or Kubernetes
Fix
Replace the path-passing with object storage URIs: producer uploads, returns s3:// URI, consumer downloads. Verify on multi-worker executors where /tmp is never shared.
Symptom · 04
Tokens visible in XCom UI and logs
Fix
Rotate any exposed secret, move it into a connection or secrets backend, and change tasks to fetch credentials via hooks. Audit run history for values that must be purged.
XCom vs Object Storage vs Database
ChannelLives inSize fitUse when
XComMetadata DBBytes to KBIDs, URIs, flags, small dicts
Object storage URI via XComS3 or GCS, pointer in DBMB to GBDataframes, files, model artifacts
Direct DB staging tableWarehouse tablesAny row volumeHandoffs analytics must query
Task logsLog storageText onlyHuman debugging, never data flow
Custom XCom backendS3/GCS via xcom_backendGB via external storeLarge ML artifacts
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
dagsorder_refs.pyfrom airflow.sdk import dag, taskImplicit Push and Pull via TaskFlow
xcom_bloat.sqlSELECT dag_id, task_id,The Size Trap and DB Bloat
dagsorder_uris.pyfrom airflow.sdk import dag, taskThe Rule

Key takeaways

1
XComs are control-plane messages in the metadata DB (Postgres 1GB, MySQL 64KB)
sized for IDs, URIs, flags, small dicts.
2
TaskFlow wires push/pull via returns and XComArgs; multiple_outputs=True splits dicts into per-key entries you'd pull by key.
3
Oversized XComs bloat scheduler tables and clear on retry
they can't persist state across tries or sensors.
4
Reference passing keeps the DB fast
artifacts in object storage or custom backend, URIs in XCom with lifecycle cleanup.
5
Secrets never ride XCom; use connections and backends, and pull cross-DAG with explicit dag_id plus run_id.

Common mistakes to avoid

4 patterns
×

Returning dataframes and multi-megabyte JSON from tasks

Symptom
Metadata DB balloons, scheduler reads slow down, and the UI times out listing XCom values.
Fix
Write the payload to S3 or GCS (or a custom XCom backend via xcom_backend) and return only the URI string. The consumer downloads by URI; the DB carries bytes of text, not megabytes.
×

Pulling XComs by wrong key and silently getting None

Symptom
Downstream tasks run on None with no error; bad rows load for days before anyone notices the missing key.
Fix
Return explicit dicts with documented keys and read them with xcom_pull(task_ids=...) by key. Add a unit test asserting the key set before wiring the DAG.
×

Passing local file paths between workers through XCom

Symptom
Works on LocalExecutor, fails on Celery and Kubernetes where workers never share /tmp.
Fix
Pass URIs and IDs through XCom, never file bytes. Scope file lifetimes to single tasks or object storage with lifecycle rules.
×

Pushing secrets and tokens through XCom

Symptom
Credentials appear in task logs and the XCom UI tab; rotation requires hunting values across run history.
Fix
Store auth material in connections and pass only references. XCom values are visible to anyone with task read access.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What are XComs and where do they live?
Q02SENIOR
Why did a 10MB XCom freeze the scheduler database?
Q03SENIOR
How do you keep XComs from becoming a data plane at scale?
Q01 of 03JUNIOR

What are XComs and where do they live?

ANSWER
XComs are small keyed values exchanged between tasks and persisted in the metadata DB. TaskFlow pushes returns automatically and pulls via XComArg wiring. I keep payloads to IDs, URIs, and small dicts, and route anything larger through object storage with only the URI in XCom.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is an XCom in one paragraph?
02
How does TaskFlow push and pull implicitly?
03
Why does a 10MB XCom freeze the scheduler DB?
04
What are the xcom_pull patterns I should know?
05
When do XComs expire?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.

Follow
Verified
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

3 min read · try the examples if you haven't

Previous
Airflow Task Lifecycle and Retries
7 / 37 · Airflow
Next
Airflow TaskFlow API