Home DevOps Airflow XComs: The 10MB Payload That Froze the Scheduler DB
Intermediate 4 min · August 1, 2026

Airflow XComs: The 10MB Payload That Froze the Scheduler DB

Airflow XComs explained: push, pull, return values, size limits, and object storage URIs.

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
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • A DAG that moves data between tasks with TaskFlow or PythonOperator.
  • Familiarity with the Airflow UI's task instance details and XCom tab.
  • Basic knowledge of S3 or GCS for the object-storage patterns.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • XComs are Airflow's metadata-DB-backed key-value store that lets tasks hand small messages and return values to each other.
  • Key components: implicit push via TaskFlow returns, xcom_pull for reads, and the xcom table in the metadata DB.
  • XComs persist in the metadata DB — one 10MB payload bloated our xcom table and slowed every scheduler read.
  • Performance rule: keep values under a few KB; large artifacts belong in object storage, with only the URI passed through XCom.
  • Production rule: pass references, not data — return keys, paths, and URIs, never payloads.
✦ Definition~90s read
What is Airflow XComs?

XComs are Airflow's metadata-DB-backed key-value store that lets tasks pass small messages and return values to each other, with TaskFlow making push and pull automatic.

XComs are the sticky notes tasks pass to each other in a pipeline.
Plain-English First

XComs are the sticky notes tasks pass to each other in a pipeline. Task A writes a note: "the file I made is at s3://bucket/out.csv". Task B reads the note and goes to fetch it. The catch: the notes are pinned to the scheduler's filing cabinet, the metadata database. Write a giant essay on a sticky note — like a 10MB JSON blob — and the filing cabinet swells, the drawers jam, and every task slows down waiting for the cabinet. Keep notes short. Put the heavy stuff in a warehouse and write the address on the note.

XComs look like the easiest way to move data between tasks. They're also the most common way to slowly kill a production scheduler. Every value a task pushes lands in the metadata DB, and the metadata DB is the same database the scheduler and the web UI hammer on every heartbeat. Bloat it and everything slows down at once.

We had a task that built a 10MB JSON dict of order summaries and returned it straight to XCom. Harmless-looking — one return statement. Inside the scheduler it was a truck dumped into a filing cabinet, and the cabinet started jamming. The UI crawled, task instance queries took seconds, and the DB grew every single run.

Here's what XComs are, how push and pull really work, and the one rule that keeps your scheduler fast: pass references, not data. It's a small rule with a 10MB lesson behind it.

1. What XComs Are (and Where They Live)

XCom is short for cross-communication. A task pushes a key-value pair into a table in the metadata DB, and any other task in any DAG can pull it back by dag_id, task_id, and key. That's the whole mechanism — a shared key-value table.

The "where they live" part is the part everyone skips: the xcom table lives in the same PostgreSQL (or SQLite) database that stores every DAG run, task instance, and scheduler state. The scheduler reads that database continuously, and the web UI reads it on every page load.

So XCom is not a data pipeline. It's a control channel that happens to be backed by your most load-sensitive table. Treat it accordingly, and the whole failure class disappears.

⚠ The Metadata DB Is a Filing Cabinet
XComs persist in the metadata DB. A big value is paid for on every read by the scheduler and the UI — not just on the write. Keep values small or every page and heartbeat pays the tax.
📊 Production Insight
XComs are a control channel, not a data bus.
They live in the same DB the scheduler reads constantly.
Treat the xcom table as load-sensitive, because it is.
🎯 Key Takeaway
XCom is a key-value note passed between tasks.
The note lives in the metadata DB, forever until cleared.
Size your notes like the DB they're stored in.
How XComs push and pull through the metadata DB XComs: A Shared Key-Value Table Push by key · pull by dag_id, task_id, key Task A pushes a key:value return values land here too xcom table (metadata DB) same PostgreSQL as runs and state Task B pulls it back any DAG: by dag_id · task_id · key THE CATCH Read by scheduler every loop and the UI on every page load A control channel, not a data pipeline small values only — it's a shared table THECODEFORGE.IO
thecodeforge.io
Airflow Xcoms

2. Implicit Push/Pull via TaskFlow

TaskFlow made XCom invisible. A @task function's return value is automatically pushed to XCom under the key return_value, and passing that result as an argument to another @task automatically pulls it. Zero explicit code — and zero awareness of the DB write happening under the hood.

That invisibility is the trap. Teams write pipelines that return pandas DataFrames, lists of dicts, and serialized models, never once looking at the XCom tab. The scheduler is writing every one of those to the metadata DB, and pickling big objects per run.

Implicit is convenient and dangerous. The moment a return value gets big, the convenience becomes a cost center. Know what your tasks return, and keep returns small by design.

nightly_orders.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from airflow.decorators import dag, task
from airflow.utils.dates import days_ago

@dag(
    schedule="@daily",
    start_date=days_ago(2),
    catchup=False,
    default_args={"retries": 2},
)
def nightly_orders():
    @task
    def count_orders():
        # small value: pushed to XCom as return_value
        return 4_320

    @task
    def send_report(order_count: int):
        # pulled from XCom automatically
        print(f"Orders processed: {order_count}")

    send_report(count_orders())

nightly_orders()
Output
Orders processed: 4320
📊 Production Insight
TaskFlow pushes returns and pulls arguments invisibly.
Invisibility hides the DB write from review.
Keep @task return values small by design.
🎯 Key Takeaway
Return values are XCom pushes, whether you asked or not.
Big returns mean big DB writes every run.
Keep returns small; the scheduler pays per read.
TaskFlow implicit XCom push and pull TaskFlow: Invisible XCom Push and Pull Return values push · arguments pull count_orders (@task) returns 4,320 → return_value xcom table (metadata DB) auto-pushed under key: return_value send_report (@task) auto-pulls order_count argument The trap: invisibility big returns mean big DB writes THECODEFORGE.IO
thecodeforge.io
Airflow Xcoms

3. xcom_pull Patterns: Single, List, by task_id

Classic operators pull explicitly with ti.xcom_pull. The three patterns you'll actually use: pull one task's return_value, pull a specific key, and pull multiple task_ids to get a list of values.

Each pattern has an exactness trap. A typo in task_id or key returns None — no error, no warning, just a quiet None that flows downstream. This is the XCom failure mode that takes longest to find, because the DAG stays green while the data rots.

The fix is the same as everywhere else in this series: explicit keys, typed returns, and a unit test that proves the pushed value is what the pulling task expects. XCom is a contract between tasks; contracts deserve tests. Two more patterns are worth keeping handy: return a dict with multiple_outputs=True (and do_xcom_push=True) and each key is pushed as its own XCom row — pullable by key, while a keyless xcom_pull still defaults to return_value. Also note the Airflow 3 behavior change: xcom_pull() without task_ids now pulls only from the current task, where Airflow 2 searched all tasks and returned the most recent value — always pass task_ids explicitly when pulling from another task.

multi_branch_aggregation.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
from airflow.operators.python import PythonOperator


def collect_counts(**context):
    ti = context["ti"]

    # pull the default return_value from one task
    total = ti.xcom_pull(task_ids="count_orders")

    # pull a specific key from a task
    by_region = ti.xcom_pull(task_ids="group_orders", key="by_region")

    # pull the return_value from several tasks into a list
    all_counts = ti.xcom_pull(
        task_ids=["count_orders", "count_refunds", "count_failures"]
    )
    return sum(all_counts)
Output
total=4320, by_region={'EMEA': 2100, 'AMER': 2220}, all_counts=[4320, 41, 7]
📊 Production Insight
A wrong task_id or key returns None, silently.
None flows downstream and rots the data quietly.
Type hints and tests make XCom contracts explicit.
🎯 Key Takeaway
xcom_pull has three patterns; use them deliberately.
Wrong keys return None, not errors.
Test the contract between pusher and puller.

4. The Size Trap and DB Bloat (Incident Deep Dive)

The incident task returned a 10MB JSON dict of order summaries. In isolation it looked fine — one task, one run. But XCom persists, so that 10MB sat in the xcom table, and every scheduler loop, every UI grid refresh, and every downstream pull read the table. Ten megabytes of tax, every read, every run, compounding.

Within days the DB had grown, queries that took milliseconds took seconds, and the scheduler fell behind. The failure wasn't a crash. It was death by slow read — the hardest outage to see coming, because nothing is red.

Airflow does offer custom XCom backends — including an official object-storage backend that keeps values out of the database entirely — but those are painkillers, not cures. The cure is size discipline at the source: return IDs, counts, and URIs. The 10MB dict belonged in S3, not the scheduler's spine.

size_guard.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
import json
from airflow.decorators import task

MAX_XCOM_BYTES = 4_096  # stay tiny: XCom lives in the metadata DB

@task

def summarize_orders(order_summaries: dict) -> str:
    payload = json.dumps(order_summaries).encode()
    if len(payload) > MAX_XCOM_BYTES:
        raise ValueError(
            f"XCom payload too large ({len(payload)} bytes) — "
            f"write to object storage and pass the URI instead"
        )
    return f"{len(order_summaries)} regions summarized"
Output
ValueError: XCom payload too large (10485760 bytes) - write to object storage and pass the URI instead
📊 Production Insight
Big XCom values are paid on every read, forever.
The incident died by slow read, not by crash.
Guard payload size in code; fail fast in CI.
🎯 Key Takeaway
A 10MB payload is a tax on every scheduler read.
The fix is size discipline at the source.
Guard returns; the DB will thank you.
XCom size trap before and after The 10MB Payload: Before vs After From slow-read outage to URI-sized XCom Before: 10MB Payload After: URI + Size Guard Push 10MB dict, every run Small URI string Storage Bloats the xcom table JSON lives in S3 Read cost Every read pays 10MB Reads stay tiny Scheduler Falls behind, nothing red Keeps pace, fast reads Guard No size check in code Fails fast over 4KB Fix: 10MB JSON to S3 — XCom carries only the URI THECODEFORGE.IO
thecodeforge.io
Airflow Xcoms

5. The Rule: Pass References, Not Data

The rule that fixes this class of incident for good: XCom carries references, never payloads. Write the heavy artifact — the DataFrame, the JSON export, the model — to object storage, and push the URI.

Why object storage and not the DB? Three reasons: S3/GCS is built for large blobs and infinite retention; the metadata DB is built for rows of state and slow reads. Object storage costs pennies per gigabyte; the scheduler's DB costs you every query. And a URI is debuggable — you can go look at the object, re-download it, and inspect it.

This pattern is idiomatic Airflow 3: write to object storage, pass the URI, read it in the next task. Same shape as the incident's fix, minus the month of pain.

pass_references.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
import boto3
from airflow.decorators import task

S3_BUCKET = "market-etl-artifacts"

@task
def export_order_summaries(order_summaries: dict) -> str:
    s3 = boto3.client("s3")
    key = "orders/summaries_2026_08_01.json"
    s3.put_object(Bucket=S3_BUCKET, Key=key, Body=json.dumps(order_summaries))
    return f"s3://{S3_BUCKET}/{key}"   # the URI, not the payload

@task
def load_summaries(summary_uri: str):
    s3 = boto3.client("s3")
    bucket, key = summary_uri.replace("s3://", "").split("/", 1)
    body = s3.get_object(Bucket=bucket, Key=key)["Body"].read()
    print(f"Loaded {len(body)} bytes from {summary_uri}")
Output
Loaded 10485760 bytes from s3://market-etl-artifacts/orders/summaries_2026_08_01.json
Mental Model
References, Not Payloads
XCom is a note passed between tasks, not a data lake — pass references, not data.
  • Return IDs, counts, and URIs — never DataFrames, dicts, or serialized models.
  • Object storage scales; the metadata DB doesn't — pick the storage by the job.
  • A URI is debuggable: you can inspect the object it points to; a blob in the DB is a black box.
📊 Production Insight
XCom carries references; object storage carries data.
S3/GCS is built for blobs; the metadata DB is built for rows.
A URI you can inspect beats a blob you can only trust.
🎯 Key Takeaway
Pass references, not data — the series rule.
Large artifacts live in object storage; URIs live in XCom.
If a value is big enough to notice, it's too big.

6. XCom-Enabled Sensors and When XComs Expire

Sensors can pass data too. A sensor task can push what it found — a file path, a query result, a row count — into XCom for the next task to consume. It's the same mechanism with a sensor-shaped entrance.

Then there's the lifecycle question: when do XComs go away? They're cleared with their task instances and DAG runs — clear a run and its XComs go with it. Old runs get cleaned up by the data retention policy on dag runs, and XComs without cleanup accumulate: that's part of the bloat math above. One doc-level rule most teams learn the hard way: if a task's first attempt fails, its XComs are cleared on every retry so the run stays idempotent — XComs cannot be used to persist state across task retries or across sensor pokes.

Know what your XComs are before you need to clean them. A quick query on the xcom table by size and age tells you who's using the channel responsibly and who's treating your scheduler's filing cabinet as a data warehouse.

📊 Production Insight
Sensors can push what they find; XCom is task-agnostic.
XComs die with their task instances and runs.
Audit the xcom table by size and age regularly.
🎯 Key Takeaway
XComs expire with their runs, not on a timer.
Retention policies clean runs; XCom bloat survives them.
Audit the xcom table before it audits you.
● Production incidentPOST-MORTEMseverity: high

The 10MB Payload That Froze the Metadata DB

Symptom
The scheduler UI crawled, task instance queries took seconds, and the metadata DB size spiked after every run. One task was writing a 10MB JSON dict to XCom on every execution.
Assumption
The team assumed XCom was a general-purpose data bus — push anything, read it anywhere, no limits worth worrying about.
Root cause
XComs live in the metadata DB in the xcom table. A 10MB value bloated the table, and every scheduler loop and UI page that reads XComs paid the price on every read.
Fix
Returned lightweight values only, wrote the large JSON to S3 and passed the URI through XCom, and added a size check in code so any payload above a few KB fails fast instead of silently bloating the DB.
Key lesson
  • XComs are metadata, not a data bus — they live in the same DB the scheduler depends on.
  • A 10MB value isn't a one-time cost; it's paid on every read, forever, until it's cleared.
  • Large artifacts belong in object storage; XCom should carry only the URI.
  • Add a payload-size guard to shared tasks so bloat fails fast in CI, not at 2 AM.
Production debug guideSymptom to Action4 entries
Symptom · 01
Scheduler and UI slow down; DB keeps growing
Fix
Query the xcom table by value size to find the offending task, then fix the task to return a URI instead.
Symptom · 02
xcom_pull returns None with no error
Fix
Verify the exact task_id and key you're reading; a wrong key returns None silently, and the None then flows downstream.
Symptom · 03
Task fails with a pickling or serialization error on push
Fix
The value is either too large or not serializable; return a key, path, or URI and write the object elsewhere.
Symptom · 04
Huge values appearing in the XCom grid view
Fix
Check the pushing task's return statement; a return of a DataFrame or dict means implicit XCom push is doing the damage.
★ Quick Debug Cheat SheetCommon XCom issues and immediate actions.
Metadata DB growing; scheduler slow
Immediate action
Find the biggest XCom rows
Commands
SELECT dag_id, task_id, OCTET_LENGTH(value) AS size FROM xcom ORDER BY size DESC LIMIT 10;
airflow dags list-runs nightly_orders
Fix now
Return a URI from the offending task, not the payload.
xcom_pull returns None silently+
Immediate action
Verify the key and pushing task
Commands
airflow tasks render nightly_orders count_orders 2026-07-15
airflow tasks test nightly_orders count_orders 2026-07-15
Fix now
Match the exact output key from the pushing task.
Pickling error on push+
Immediate action
Check payload size and type
Commands
SELECT COUNT(*) FROM xcom WHERE dag_id = 'nightly_orders';
airflow config get-value core enable_xcom_pickling
Fix now
Write the object to S3/GCS and push only the reference.
Moving Data Between Tasks
OptionWhere it livesSize ceilingBest for
XCom (default backend)Metadata DB xcom tableA few KB — anything more is a tax on every readReturn values, IDs, counts, URIs
Object storage + URI (S3/GCS)External object store; URI in XComEffectively unlimitedFiles, exports, DataFrames, serialized models
Direct DB write from the taskYour warehouse or app DBBounded by the target tableData that downstream SQL needs anyway
Custom XCom backend (compressed/DB)Compressed in the metadata DB or another storeLarger, at the cost of complexityTeams that must keep XCom but outgrow it
⚙ Quick Reference
4 commands from this guide
FileCommand / CodePurpose
nightly_orders.pyfrom airflow.decorators import dag, task2. Implicit Push/Pull via TaskFlow
multi_branch_aggregation.pyfrom airflow.operators.python import PythonOperator3. xcom_pull Patterns
size_guard.pyfrom airflow.decorators import task4. The Size Trap and DB Bloat (Incident Deep Dive)
pass_references.pyfrom airflow.decorators import task5. The Rule

Key takeaways

1
XComs are a metadata-DB-backed control channel for small values, not a data bus.
2
TaskFlow makes pushes and pulls implicit
invisibility hides the DB write from review.
3
A wrong key returns None silently; use explicit keys and typed contracts between tasks.
4
Large payloads belong in object storage; XCom should carry only the URI.
5
Audit the xcom table by size and age so bloat fails in review, not in production.

Common mistakes to avoid

4 patterns
×

Returning big DataFrames or dicts from @task functions

Symptom
Metadata DB grows every run; scheduler and UI slow down with no visible error
Fix
Write the artifact to object storage and return the URI; keep @task returns under a few KB.
×

Reading xcom_pull with a wrong task_id or key

Symptom
Silent None flows downstream; the DAG stays green while the data rots
Fix
Use explicit keys and typed signatures, and unit-test the pusher-puller contract.
×

Relying on the default XCom backend for heavy values

Symptom
Pickling failures on large or unserializable objects, plus slow scheduler reads
Fix
Pass references, not data; only reach for custom backends when size discipline alone can't fit.
×

Never auditing XCom usage

Symptom
Bloat accumulates across runs and DAGs until a slow-scheduler incident surfaces it
Fix
Query the xcom table by value size quarterly and gate payloads in shared task code.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is an XCom and how do tasks share data in Airflow?
Q02SENIOR
The scheduler is slowing down and the xcom table is huge. What do you do...
Q03SENIOR
Design data passing for a pipeline that moves a 2GB file between tasks.
Q01 of 03JUNIOR

What is an XCom and how do tasks share data in Airflow?

ANSWER
XCom (cross-communication) is a key-value store backed by the metadata DB. A task pushes a value under a key, and other tasks pull it by dag_id, task_id, and key. TaskFlow makes this implicit: return values are pushed automatically and passed results are pulled automatically. It's for small control values, not bulk data.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Where are XCom values stored?
02
What is the size limit for XCom values?
03
How do I pass large data between tasks?
04
When do XComs expire or get cleared?
05
Can I see XCom values in the UI?
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
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

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

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