Airflow XComs: The 10MB Payload That Froze the Scheduler DB
Airflow XComs explained: push, pull, return values, size limits, and object storage URIs.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓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.
- 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.
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.
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.
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: without xcom_pull()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.
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.
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.
- 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.
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.
The 10MB Payload That Froze the Metadata DB
- 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.
SELECT dag_id, task_id, OCTET_LENGTH(value) AS size FROM xcom ORDER BY size DESC LIMIT 10;airflow dags list-runs nightly_orders| File | Command / Code | Purpose |
|---|---|---|
| nightly_orders.py | from airflow.decorators import dag, task | 2. Implicit Push/Pull via TaskFlow |
| multi_branch_aggregation.py | from airflow.operators.python import PythonOperator | 3. xcom_pull Patterns |
| size_guard.py | from airflow.decorators import task | 4. The Size Trap and DB Bloat (Incident Deep Dive) |
| pass_references.py | from airflow.decorators import task | 5. The Rule |
Key takeaways
Common mistakes to avoid
4 patternsReturning big DataFrames or dicts from @task functions
Reading xcom_pull with a wrong task_id or key
Relying on the default XCom backend for heavy values
Never auditing XCom usage
Interview Questions on This Topic
What is an XCom and how do tasks share data in Airflow?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
That's Airflow. Mark it forged?
4 min read · try the examples if you haven't