Home DevOps Airflow Object Storage: Fix the Shared /tmp Nightmare
Advanced 3 min · September 04, 2026
Airflow Object Storage Workflows

Airflow Object Storage: Fix the Shared /tmp Nightmare

Airflow object storage replaces flaky shared /tmp with ObjectStoragePath URIs on S3 and GCS.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Production
production tested
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 25 min
  • 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)
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • 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
✦ Definition~90s read
What is Airflow Object Storage Workflows?

Airflow object storage is the Airflow 3 pattern of staging task artifacts at bucket URIs via ObjectStoragePath and passing pointers between tasks. It replaces worker-local /tmp sharing with deterministic, portable artifact flow.

Picture two cooks in different kitchens sharing one recipe that says leave the sauce on your own counter for the other cook to grab.
Plain-English First

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.

📊 Production Insight
Single-worker history masks broken sharing for months. Placement spread at scale reveals it. Rule: no cross-task path may start with /tmp.
🎯 Key Takeaway
Worker disks are private and placement is luck. Force tasks onto different workers in testing and every /tmp handoff fails on demand instead of in prod.

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.

scripts/object-storage-setup.shBASH
1
2
3
4
5
6
7
8
9
10
11
pip install 'apache-airflow-providers-amazon[s3fs]'  # fsspec backend for S3

# scoped writer connection (least privilege on the artifacts bucket)
airflow connections add aws_reports \
  --conn-type aws \
  --conn-login "$AWS_ACCESS_KEY_ID" \
  --conn-password "$AWS_SECRET_ACCESS_KEY" \
  --conn-extra '{"region_name": "us-east-1"}'

# smoke test from any worker
python3 -c "from airflow.sdk import ObjectStoragePath; b = ObjectStoragePath('s3://aws_reports@acme-artifacts/'); b.mkdir(exist_ok=True); print('bucket OK:', b)"
📊 Production Insight
Connections per environment beat ambient credentials per image. Scoped IAM contains breaches to one bucket. Rule: rotate in the connection, never in code.
🎯 Key Takeaway
Buckets outlive pods and serve identical bytes to any worker. Wire them through conn_id credentials per environment and install the fsspec extra first.

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.

dags/sales_stage.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
28
29
30
31
32
33
34
35
36
37
38
39
import pendulum
from airflow.sdk import ObjectStoragePath, dag, task

base = ObjectStoragePath("s3://aws_reports@acme-artifacts/sales/")

@dag(
    dag_id="sales_stage",
    schedule="0 6 * * *",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    tags=["sales", "object-storage"],
)
def sales_stage():
    @task
    def extract(logical_date: str | None = None) -> str:
        import pandas as pd

        base.mkdir(exist_ok=True)
        path = base / f"raw_{logical_date}.parquet"
        df = pd.DataFrame({"order_id": [1, 2, 3], "amount": [19.0, 42.0, 7.5]})
        with path.open("wb") as f:
            df.to_parquet(f)
        return str(path)  # pointer travels, bytes stay in the bucket

    @task
    def transform(path: str) -> str:
        import pandas as pd

        src = ObjectStoragePath(path, conn_id="aws_reports")
        with src.open("rb") as f:
            df = pd.read_parquet(f)
        out = base / ("clean_" + src.name)
        with out.open("wb") as f:
            df.dropna().to_parquet(f)
        return str(out)

    transform(extract())

sales_stage()
🔥Pathlib for Buckets
ObjectStoragePath works like pathlib for buckets: slash joins paths, open streams bytes, iterdir lists. Learn four methods and you know the whole API surface.
📊 Production Insight
URI strings serialize; live file handles don't. Same code ports clouds by swapping scheme. Rule: tasks return str(path), never bytes.
🎯 Key Takeaway
Scheme picks the backend, conn_id picks credentials, strings cross task boundaries. Return URIs from tasks and rebuild paths downstream.

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.

📊 Production Insight
Immutable staged objects make retries safe by construction. Dated prefixes isolate runs. Rule: rewrite same keys on rerun, never append.
🎯 Key Takeaway
Dated prefixes plus immutable objects make every stage retryable and reruns convergent. Chain URI returns through TaskFlow and the path becomes the lineage.

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.

dags/sales_analyze.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
import duckdb
from airflow.sdk import ObjectStoragePath, dag, task
import pendulum

@dag(
    dag_id="sales_analyze",
    schedule="0 7 * * *",
    start_date=pendulum.datetime(2026, 1, 1, tz="UTC"),
    catchup=False,
    tags=["sales", "analytics"],
)
def sales_analyze():
    @task
    def summarize(path: str) -> str:
        src = ObjectStoragePath(path, conn_id="aws_reports")
        conn = duckdb.connect(database=":memory:")
        conn.register_filesystem(src.fs)  # reuse Airflow creds in duckdb
        total = conn.execute(
            f"SELECT sum(amount) FROM read_parquet('{src}')"
        ).fetchone()[0]
        return f"total={total}"

    summarize(path="s3://aws_reports@acme-artifacts/sales/clean_raw_2026-09-04.parquet")

sales_analyze()
📊 Production Insight
Small XCom payloads grow 1000x within quarters. URI-only is the only stable rule. Rule: over a few KB, reference it or reject the PR.
🎯 Key Takeaway
Pointers weigh bytes, artifacts weigh megabytes. Enforce URI-passing in review and let engines read from the bucket with Airflow credentials.

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.

📊 Production Insight
Forgotten staging is 80% of object-storage spend. Lifecycle rules delete while you sleep. Rule: staging expires, promotions version, everything tagged.
🎯 Key Takeaway
Expire staging in 14 days, version promotions, tag by DAG for chargeback. Quarterly prefix audits keep forgotten scratch from tripling the bill.
● Production incidentPOST-MORTEMseverity: high

The Shared /tmp That Made Tasks Nondeterministic

Symptom
The same DAG run passed on retry and failed on the next attempt with no code change. Outputs varied per run: missing files, truncated frames, stale reads from previous runs. Failures clustered as the worker pool grew, and logs showed consumers opening paths the producer had written on a different host.
Assumption
The team assumed workers shared a filesystem because early runs shared a host. Local development ran everything on one machine, staging had a single worker, and the first production months stayed small enough that placement usually collided. Same-machine success masqueraded as correct design.
Root cause
Tasks on different workers raced over a shared local temp path that was never actually shared. One worker's writes were invisible to the other, so consumers read missing or half-written files. Early single-worker history hid the flaw until the fleet grew and placement spread tasks across machines.
Fix
All cross-task artifacts moved to object storage behind ObjectStoragePath URIs, with the conn_id baked into each URI per environment. Tasks now return path strings and open them on whatever worker runs next. A 14-day lifecycle rule caps staging costs, and XComs carry only pointers. Reruns produce identical results regardless of worker placement.
Key lesson
  • 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.
Production debug guideFour shared-filesystem failures, with the exact commands that make each deterministic.4 entries
Symptom · 01
Tasks pass or fail depending on which worker they land on
Fix
Grep DAGs for /tmp usage across task boundaries: grep -rn '/tmp' dags/ | grep -v import. For each cross-task path, rewrite the producer to write an ObjectStoragePath URI and the consumer to open that URI. Rerun with tasks forced onto different workers to prove determinism.
Symptom · 02
ObjectStoragePath raises auth or not-found errors
Fix
Test the connection directly: python3 -c "from airflow.sdk import ObjectStoragePath; print(list(ObjectStoragePath('s3://aws_reports@acme-artifacts/').iterdir()))". A 403 means the worker IAM role lacks scope; a resolution error means the conn_id is wrong. Fix the connection, not the DAG. Verify the import (airflow.sdk on 3.x) and the s3fs extra — both break path resolution before any credential is even tried.
Symptom · 03
Metadata DB grows fast and XCom reads slow down
Fix
Check value sizes flowing through XCom in the UI's XCom tab for the run. Anything over a few KB moves to the bucket: producer writes the file and returns str(path), consumer opens it. Confirm metadata DB size stops growing after the change.
Symptom · 04
Bucket bill climbs every month with no new pipelines
Fix
List bucket prefixes by age: aws s3 ls s3://acme-artifacts/staging/ --recursive | head -30 and check for months-old objects. Add a 14-day lifecycle rule on staging prefixes and tag objects with the producing dag_id for chargeback.
Passing Data Between Tasks Compared
ChannelSurvives worker change?Size limitUse when
Local /tmp fileNo, worker-local onlyDisk sizeNever across tasks in prod
XCom valueYes, via metadata DBKBs, keep tinyIDs, flags, tiny results
Object storage URIYes, shared truthTBsFiles, frames, artifacts
Database rowYes, queryableRow limitsStructured records to query
Asset eventYes, plus triggers downstreamMetadata onlySignaling completion downstream
path.fs handoffSecond credential chain for analytics enginesregister_filesystem(path.fs), read directtasks
Backend extra installS3 paths that never resolveproviders-amazon[s3fs] for aiobotocoretasks
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
scriptsobject-storage-setup.shpip install 'apache-airflow-providers-amazon[s3fs]' # fsspec backend for S3Object Storage as the Shared Truth
dagssales_stage.pyfrom airflow.sdk import ObjectStoragePath, dag, taskAirflow 3 Object Storage Abstractions
dagssales_analyze.pyfrom airflow.sdk import ObjectStoragePath, dag, taskPass URIs, Not Bytes

Key takeaways

1
Never share /tmp between workers
one URI in object storage both tasks can see beats affinity rules and NFS prayers.
2
Build paths as protocol://[conn_id@]bucket/key with from airflow.sdk import ObjectStoragePath; explicit conn_id= wins over the URI userinfo.
3
Install the backend extra (apache-airflow-providers-amazon[s3fs] for S3)
missing aiobotocore is the top reason S3 paths won't resolve.
4
Define paths at DAG top level freely since connections resolve lazily; pass serialized paths (not bytes) through XCom.
5
Hand path.fs to DuckDB/Iceberg/pandas for zero-credential-juggling reads, and set lifecycle rules so staging prefixes expire instead of billing forever.

Common mistakes to avoid

4 patterns
×

Sharing task outputs through local /tmp paths

Symptom
Task B reads a file task A wrote, but lands on a different worker and finds nothing; reruns pass or fail depending on worker placement lottery.
Fix
Write every cross-task artifact to object storage and pass the URI string between tasks. Local disk is a cache with no promises; the bucket is the shared truth both workers can see.
×

Relying on ambient cloud credentials instead of conn_id

Symptom
Tasks pass on one worker image and fail with 403 on another; a credential rotation fixes half the DAGs and breaks the rest.
Fix
Bake the conn_id into the URI (s3://aws_reports@bucket/path) or pass conn_id explicitly. Ambient credentials differ per worker image and rotation breaks half the fleet at once. Use path.fs with conn.register_filesystem so DuckDB inherits Airflow's authenticated filesystem instead of a second credential chain.
×

Pushing file bytes through XCom

Symptom
The metadata DB bloats, scheduler queries slow down, and large values get truncated or rejected while small-pointer DAGs hum along.
Fix
Return the ObjectStoragePath or its URI string from the task and let Airflow serialize that. Payloads over a few KB belong in the bucket, with only the pointer traveling through the metadata DB.
×

Staging objects with no lifecycle policy

Symptom
The bucket bill triples in a quarter; 80% of objects are staging files from DAG runs nobody will ever re-read.
Fix
Set lifecycle rules expiring staging prefixes after 14 days and tag artifacts by producing DAG. Storage bills what you forget to delete, and forgotten staging prefixes compound monthly.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
Why does local /tmp make distributed tasks nondeterministic?
Q02SENIOR
Explain the ObjectStoragePath URI anatomy.
Q03JUNIOR
When do you use XCom versus object storage?
Q01 of 03SENIOR

Why does local /tmp make distributed tasks nondeterministic?

ANSWER
Workers are fungible and local disks are not shared, so a file written to /tmp on worker A is invisible to task B on worker B. Races and placement lottery make results nondeterministic. Object storage gives every worker the same view: tasks write artifacts to URIs and pass the URI string, so any worker can continue the chain deterministically.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is ObjectStoragePath?
02
How do tasks pass files without XCom bloat?
03
What setup does S3 access need?
04
Can I still use XComs at all?
05
How long should staged objects live?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

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 Deferrable Operators and Triggerer
34 / 37 · Airflow
Next
Airflow Human in the Loop Approvals