Airflow Object Storage: Fix the Shared /tmp Nightmare
Airflow object storage replaces flaky shared /tmp with ObjectStoragePath URIs on S3 and GCS.
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
- ✓An Airflow 3.x deployment with a cloud bucket available
- ✓A connection with scoped write access to that bucket
- ✓Basic Python file handling (open, pathlib)
- Airflow object storage gives distributed tasks one shared truth: artifacts live at URIs, tasks pass pointers, every worker sees the same data
- Key components: ObjectStoragePath URIs with conn_id, provider fsspec extras, staging prefixes, and lifecycle rules
- Performance insight: URI passing adds milliseconds per handoff while removing 100% of placement-lottery failures that once took hours to diagnose
- Production insight: one team's shared /tmp pipeline gave different answers per run until artifacts moved to S3 URIs
- Biggest mistake: pushing file bytes through XCom, bloating the metadata DB that every scheduler query must scan
Picture two cooks in different kitchens sharing one recipe that says leave the sauce on your own counter for the other cook to grab. Object storage is a shared pantry both kitchens can reach: the first cook leaves the sauce on a labeled shelf, hands over the shelf number, and the second cook always finds it.
Distributed workers share nothing. Not memory, not disk, and especially not /tmp. Every DAG that assumes otherwise works until the scheduler places two tasks on different machines.
One team's pipeline wrote intermediate files to a shared /tmp path. Some runs landed both tasks on one worker and passed; others split across workers and produced garbage. Same code, different answers.
Object storage ends the lottery. Write artifacts to URIs, pass pointers between tasks, and you'll get the same truth on every worker. Deterministic. Portable.
Local Filesystem Traps on Distributed Workers
Worker disks are private by architecture. The scheduler places tasks wherever capacity exists, evictions reschedule mid-run, and autoscaling replaces hosts beneath you. Any path starting with /tmp is a promise the platform never made.
You'll notice the trap in single-worker history. Dev runs everything locally, staging runs one worker, and early prod is small enough that tasks collide on hosts. The DAG looks correct for months, then the fleet grows and placement spreads.
Prove sharing is broken before it matters. Force consecutive tasks onto different workers on staging and watch /tmp handoffs fail on demand. A test that fails deterministically beats an incident that fails randomly.
The failure mode is always the same: /tmp looks shared on one box and isn't on two. One worker writes, the scheduler schedules the reader elsewhere, and the file simply isn't there. Object storage ends the guessing — one URI both tasks can see, no affinity rules, no NFS prayers.
Object Storage as the Shared Truth
Object storage is the shared truth every worker can reach. Buckets persist beyond pods, replicate across zones, and serve identical bytes to any host with credentials. Tasks stop caring where they run.
You'll access it through connections, not SDK config. The conn_id selects credentials per environment, IAM scopes them to the artifacts bucket, and rotation happens in one place. Prod and dev differ by connection, never by code.
Install the fsspec extra for your backend. S3 needs the amazon provider's s3fs extra, GCS its gcsfs equivalent. Without it the URI scheme has no driver and every path operation fails the same way.
Airflow 3 Object Storage Abstractions
ObjectStoragePath feels like pathlib because it is modeled on it. Slash joins segments, open streams bytes, iterdir lists, mkdir ensures prefixes. You'll learn four methods and port the pattern across providers.
The URI carries everything. Scheme selects the backend, conn_id selects credentials, path selects the object. Swap s3 for gs plus a GCP conn_id and identical task code runs on the other cloud.
Serialize paths, not handles. Tasks return the URI string; downstream tasks rebuild the path object with their conn_id. Strings cross XCom cleanly while live handles cannot.
Import from the right place on Airflow 3: from airflow.sdk import ObjectStoragePath (the old airflow.io.path location moved). S3 needs pip install apache-airflow-providers-amazon[s3fs] — it pulls aiobotocore, which is deliberately excluded from the default install to dodge botocore conflicts, so missing-s3fs is the first thing to check when S3 paths won't resolve. The URI shape is protocol://[conn_id@]bucket/key: the protocol picks the backend (s3, gs, azure), the userinfo slot carries the conn_id, or pass conn_id= explicitly (explicit wins). Omit it and Airflow falls back to that backend's default connection.
Create paths freely at DAG top level — connections resolve lazily on first use, not at import, so defining base = ObjectStoragePath(...) globally never opens a network call during parsing. Paths serialize cleanly (path, conn_id, kwargs round-trip), which is exactly how you pass them through XCom between tasks without pickling filesystems.
Staging Patterns: Upload, Transform, Download
The staging pattern has three steps. Upload raw artifacts under a dated prefix, transform by reading URIs and writing new URIs, and let consumers download only what they need. Each step is independently retryable because inputs are immutable objects.
You'll namespace by DAG, date, and stage. sales/2026-09-04/raw_ plus clean_ prefixes keep runs isolated and reruns idempotent: rewriting the same keys converges instead of duplicating. Debugging starts with listing one prefix.
Chain through return values. TaskFlow passes the URI string downstream implicitly, so the graph reads extract to transform to load with no manual XCom calls. The lineage is the path itself.
Lean on the pathlib shape: / joins keys, mkdir(exist_ok=True) ensures prefixes, open('wb'/'rb') streams bytes, stat() gives st_size/st_mtime plus backend extras (ETag, ContentType on S3 — don't rely on those cross-backend). copy/move follow fsspec semantics, including remote-to-remote streaming that walks the tree file by file; same-store copies stay optimized. Need DuckDB, Iceberg, or pandas on the other end? Grab path.fs — the fsspec filesystem authenticated from your conn_id — and hand it over: conn.register_filesystem(path.fs) lets DuckDB read_parquet straight off the path with Airflow's credentials. Custom store? Register once with attach() at DAG top level so every task reuses it.
Pass URIs, Not Bytes
Pass URIs, not bytes, between every task. The pointer weighs a hundred bytes; the parquet weighs a hundred megabytes. XComs stay tiny, the metadata DB stays fast, and workers fetch directly from the bucket at full speed.
You'll enforce a size rule in review. Anything over a few KB travels by reference, no exceptions. The one team that allowed small frames through XCom watched small become 10MB within a quarter.
Downstream engines join the pattern. DuckDB, pandas, and Iceberg all accept fsspec filesystems or URIs, so analysis tasks reuse Airflow credentials instead of minting their own. One credential path, every engine.
Cost and Lifecycle of Staged Objects
Storage bills what you forget. Staging prefixes accumulate run after run, each harmless alone, each billed forever together. One team's bucket tripled in a quarter with zero new pipelines.
You'll expire aggressively. Fourteen-day lifecycle rules on staging prefixes delete scratch automatically while promoted outputs version explicitly. Tag objects by producing DAG so chargeback shows which team funds the growth.
Audit quarterly with one listing. Sort prefixes by size, name the top three owners, and ask each whether the data earns its rent. Deletion is the only optimization that compounds.
The Shared /tmp That Made Tasks Nondeterministic
- Workers are fungible by design, so local disk is never shared state. Anything two tasks must both see belongs in object storage from day one.
- Pass references, not bytes. URIs through XCom plus bytes in the bucket keeps the metadata DB fast and the pipeline portable.
- Nondeterminism that depends on placement hides until scale. Force tasks onto different workers in testing to prove determinism early.
| File | Command / Code | Purpose |
|---|---|---|
| scripts | pip install 'apache-airflow-providers-amazon[s3fs]' # fsspec backend for S3 | Object Storage as the Shared Truth |
| dags | from airflow.sdk import ObjectStoragePath, dag, task | Airflow 3 Object Storage Abstractions |
| dags | from airflow.sdk import ObjectStoragePath, dag, task | Pass URIs, Not Bytes |
Key takeaways
Common mistakes to avoid
4 patternsSharing task outputs through local /tmp paths
Relying on ambient cloud credentials instead of conn_id
Pushing file bytes through XCom
Staging objects with no lifecycle policy
Interview Questions on This Topic
Why does local /tmp make distributed tasks nondeterministic?
Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.
That's Airflow. Mark it forged?
3 min read · try the examples if you haven't