Airflow XComs: The 10MB Payload That Froze the Database
Airflow XComs live in the metadata DB, so a 10MB payload froze scheduling fleet-wide.
20+ years shipping production infrastructure and CI/CD at scale. Notes here come from systems that actually shipped.
- ✓TaskFlow basics: @dag and @task wiring
- ✓Know where the metadata DB fits in Airflow
- ✓An S3 or GCS bucket for the reference pattern
- 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
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.
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: 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.xcom_pull()
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 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>.
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.
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.
The 10MB Payload That Froze the DB
- 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.
| File | Command / Code | Purpose |
|---|---|---|
| dags | from airflow.sdk import dag, task | Implicit Push and Pull via TaskFlow |
| xcom_bloat.sql | SELECT dag_id, task_id, | The Size Trap and DB Bloat |
| dags | from airflow.sdk import dag, task | The Rule |
Key takeaways
Common mistakes to avoid
4 patternsReturning dataframes and multi-megabyte JSON from tasks
Pulling XComs by wrong key and silently getting None
Passing local file paths between workers through XCom
Pushing secrets and tokens through XCom
Interview Questions on This Topic
What are XComs and where do they live?
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?
3 min read · try the examples if you haven't