Home DevOps Airflow Object Storage: The /tmp That Broke Task Determinism
Advanced 4 min · August 1, 2026

Airflow Object Storage: The /tmp That Broke Task Determinism

Airflow object storage replaces the shared /tmp that broke reruns.

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
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • Airflow 2.10+ or Airflow 3 with object storage IO enabled.
  • Basic XCom knowledge and the pass-references-not-data rule.
  • An S3 bucket (or GCS/Azure equivalent) and a configured connection.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • Airflow object storage is the Airflow 3 abstraction that gives every task the same shared, reliable filesystem view.
  • Key pieces: ObjectStoragePath, providers for S3/GCS/Azure, and URI-based staging between tasks.
  • A task writing to local /tmp on worker A is invisible to worker B; an object URI is visible to every worker, every rerun.
  • Deterministic keys partitioned by data_interval make reruns idempotent: the same run reads the same files.
  • Pass URIs, not bytes — your XComs stay small and the metadata DB stays fast.
✦ Definition~90s read
What is Airflow Object Storage?

Airflow object storage is Airflow 3's abstraction over S3, GCS, and Azure Blob — a URI-based, task-agnostic filesystem view that lets tasks share and stage artifacts without depending on any worker's local disk.

Two cooks in two kitchens prepping for the same dinner.
Plain-English First

Two cooks in two kitchens prepping for the same dinner. The first writes his prep list on a scrap of paper on his own counter; the second never sees it, so he re-peels the same vegetables differently. Object storage is the shared whiteboard on the wall: both cooks write and read the same board, so dinner comes out the same every single night.

Every distributed system learns the same lesson the hard way: local disk is a lie. Airflow runs each task on whatever worker has a free slot, and when a task writes to /tmp, it writes to that worker's local filesystem — not a shared one. The next task on another worker can't see the file, and the rerun on a third worker can't reproduce the output.

So your pipeline becomes nondeterministic. The same DAG, the same input, different results depending on which machine happened to run each task. Teams debug this for weeks, blaming the data, the timing, the pandas version — when the real culprit is a file path that exists on exactly one machine.

Airflow 3's object storage layer is the shared whiteboard: a uniform ObjectStoragePath API over S3, GCS, and Azure Blob, with operators built around staging files.

You still pass small values through XComs. Everything else — files, parquets, CSVs, model artifacts — belongs in object storage, addressed by URI.

/usr/local/airflow/tmp isn't scratch space. It's a bug generator. Move the bytes, keep the references.

1. Local Filesystem Traps on Distributed Workers

A worker's local disk is private property. With LocalExecutor everything runs on one machine and /tmp looks shared — which is exactly why the habit survives. The moment you move to Celery or Kubernetes, each task can land on a different worker, container, or pod, each with its own filesystem that dies with the machine.

Retries make it worse. A task that retries often runs on a different worker than the first attempt, so even files written and read by the same task aren't guaranteed to be visible to itself. The failure is intermittent, which makes it look like flaky data instead of broken storage.

Nothing in Airflow synchronizes worker filesystems. Shared NFS mounts are a maintenance nightmare and a single point of failure; the platform's answer is to not need shared state at all.

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


@task
def extract_orders():
    # WRONG: worker-local path, invisible to every other worker
    with open("/tmp/orders.csv", "w") as f:
        f.write(download_orders())


@task
def load_orders():
    # WRONG: may not exist on THIS worker, may be stale elsewhere
    with open("/tmp/orders.csv") as f:
        df = pd.read_csv(f)
        write_to_warehouse(df)
Output
Traceback (most recent call last): FileNotFoundError: [Errno 2] No such file or directory: '/tmp/orders.csv'
⚠ Local disk is not shared state
The scheduler doesn't route tasks to the worker that wrote a file — it routes to the worker with a free slot. Design every task as if it runs on a machine that has never seen the other tasks' files.
📊 Production Insight
Worker local disk dies with the machine.
Retries can land on a different worker than attempt one.
Never rely on cross-task local paths in a distributed executor.
🎯 Key Takeaway
Each worker has its own filesystem.
Tasks must assume no shared local state exists.
Interleaved successes and failures = storage trap.
The /tmp Trap on Distributed Workers The /tmp Trap on Distributed Workers Worker A writes it; Worker B has never seen it Worker A writes /tmp/orders.csv the file dies with the machine Worker B reads the same path FileNotFoundError, every time Retries land on a different worker same task can lose its own file The failure is intermittent looks like flaky data, not storage Nothing syncs worker filesystems shared state must live outside workers Design for a fresh machine no cross-worker local paths, ever THECODEFORGE.IO
thecodeforge.io
Airflow Object Storage

2. Object Storage as the Shared Truth

Object storage is the shared whiteboard: a bucket everyone can read and write, addressed by URI, durable beyond any single worker's lifetime. S3, GCS, and Azure Blob all work the same way — you write an object at a key, and any process with credentials can read it back, from anywhere, at any time.

This is what makes tasks deterministic. Extract writes to s3://landing/orders/2026-07-31/orders.csv. Transform reads that exact URI. A rerun of the same interval reads the same object and produces the same output — no hidden state, no machine identity, no luck.

The durability is the second win. A worker that dies mid-task loses its local files; a bucket retains objects until a policy deletes them. The artifact outlives the run, which is what makes reruns, audits, and debugging actually possible.

📊 Production Insight
A URI is a promise: same key, same bytes, everywhere.
Object storage outlives the workers that wrote to it.
Determinism starts with where state lives.
🎯 Key Takeaway
Object storage is the shared, durable truth.
Tasks read and write by URI, never by machine.
Reruns reproduce exactly because objects persist.

3. Airflow 3 Object Storage Abstractions

Airflow 3 wraps the cloud filesystems behind one API: ObjectStoragePath. You get pathlib-style operations — read_bytes, write_bytes, mkdir, list — that work identically against S3, GCS, Azure Blob, and local paths, selected by URI scheme. The connection and its credentials come from the usual connection/secret-backend machinery.

The abstraction matters more than the syntax. A task written against ObjectStoragePath is portable: the same DAG can run against a local minio bucket in dev and production S3 in prod, with the connection handling the switch. No cloud-specific code in your tasks.

Keep object storage for objects. The metadata DB still holds XComs and run state; the bucket holds bytes. That split is the whole architecture.

Two details from the docs that trip people up. The connection id can ride in the URI itself — s3://aws_default@my-bucket/ — or be passed as conn_id=..., and the keyword argument always wins; connections resolve lazily, so creating an ObjectStoragePath at DAG scope is safe. Provider support is opt-in: s3 needs apache-airflow-providers-amazon[s3fs], gcs needs the google provider, and the file scheme works out of the box. In Airflow 3 the public import moved to airflow.sdk.

stage_with_object_storage.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
from airflow.decorators import task
from airflow.io.path import ObjectStoragePath


@task
def stage_feed(data_interval_end: str) -> str:
    src = ObjectStoragePath(f"s3://landing/feeds/{data_interval_end}.csv")
    dst = ObjectStoragePath(f"s3://staging/feeds/{data_interval_end}/feed.csv")
    dst.parent.mkdir(parents=True, exist_ok=True)
    dst.write_bytes(src.read_bytes())
    return dst.as_uri()  # push the URI, not the bytes
Output
Task stage_feed returned: s3://staging/feeds/2026-07-31/feed.csv
Mental Model
References, not payloads
XCom is the index card; object storage is the filing cabinet.
  • Small values (URIs, counts, flags) belong in XCom.
  • Everything bigger belongs in object storage.
  • The metadata DB stays fast when it stores references, not bytes.
📊 Production Insight
ObjectStoragePath is one API across all cloud providers.
Connections switch dev and prod backends transparently.
Bytes go to buckets; references go to XCom.
🎯 Key Takeaway
Airflow 3 standardizes cloud storage behind ObjectStoragePath.
Tasks stay portable across providers.
The DB holds references; buckets hold bytes.
ObjectStoragePath: One API, Every Cloud ObjectStoragePath: One API, Every Cloud Airflow 3 wraps S3, GCS, Azure, and local paths ObjectStoragePath read_bytes, write_bytes, mkdir, list S3 GCS Azure Blob Local / minio URI scheme picks the backend same DAG, dev and prod connections Bytes live in the bucket DB holds XComs and run state THECODEFORGE.IO
thecodeforge.io
Airflow Object Storage

4. Staging Patterns: Upload, Transform, Download

The production pattern is three phases, each writing to a named prefix. Ingest tasks land raw source files under raw/, transform tasks read raw/ and write clean/ with deterministic keys, and load tasks read clean/ into the warehouse. Every phase's output is addressable, auditable, and replayable.

Deterministic keys are the discipline that makes it work. Key by data_interval_end — s3://warehouse/orders/2026-07-31/ — so a rerun writes and reads exactly the same objects as the original run. Never key by timestamp or run_id; those defeat rerun idempotency.

This also fixes downstream trust. When a warehouse team asks where a number came from, the answer is a URI chain: clean/2026-07-31/orders.csv produced by transform, fed from raw/2026-07-31/feed.json. Lineage you can point at.

Because ObjectStoragePath is built on fsspec, warehouse tooling can read staged parquet with Airflow's credentials — conn.register_filesystem(path.fs) in DuckDB reads the object directly, no boto3 or cloud SDK in task code.

three_phase_dag.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
from airflow import DAG
from airflow.decorators import task
from datetime import datetime

with DAG(
    dag_id="orders_pipeline",
    schedule="0 3 * * *",
    start_date=datetime(2026, 1, 1),
    catchup=False,
    max_active_runs=1,
) as dag:

    @task
    def ingest(interval: str) -> str:
        src = ObjectStoragePath(f"s3://raw/orders/{interval}/feed.json")
        return src.as_uri()

    @task
    def transform(src_uri: str) -> str:
        raw = ObjectStoragePath(src_uri)
        clean = ObjectStoragePath(f"s3://clean/orders/{src_uri.split('/')[-2]}/orders.parquet")
        clean.parent.mkdir(parents=True, exist_ok=True)
        clean.write_bytes(normalize(raw.read_bytes()))
        return clean.as_uri()

    @task
    def load(clean_uri: str):
        run_load(clean_uri)

    load(transform(ingest("{{ data_interval_end }}")))
Output
ingest → s3://raw/orders/2026-07-31/feed.json
→ transform → s3://clean/orders/2026-07-31/orders.parquet
→ load → MERGE orders_ods ...
📊 Production Insight
raw/ → clean/ → warehouse: name every stage.
Key objects by data_interval_end, never run_id.
A URI chain is lineage you can point at.
🎯 Key Takeaway
Stage artifacts into named prefixes per phase.
Deterministic keys make reruns byte-identical.
Every artifact gets a home and a key.

5. Pass URIs, Not Bytes

XComs live in the metadata DB, and the metadata DB is a relational database, not a file server. A 10MB JSON payload doesn't just bloat one row — it slows every query the scheduler, webserver, and UI run against that table. The earlier incident in this series froze the whole scheduler on exactly this mistake.

The rule is binary: small values, like URIs, counts, and flags, travel through XCom; everything else travels through object storage, and XCom carries only the reference. A transform task returns s3://clean/orders/2026-07-31/orders.parquet — 60 bytes — not the parquet.

This pays off twice. The DB stays fast, and the data is where debugging tools can reach it: you can diff two reruns' artifacts in the bucket instead of digging through task logs.

📊 Production Insight
XCom is the metadata DB — keep it lean.
URIs are bytes you never have to move.
Buckets are debuggable; DB blobs are not.
🎯 Key Takeaway
Pass URIs, never payloads, through XCom.
The DB stores references; buckets store bytes.
Small XComs keep the scheduler fast.
Where Should This Artifact Live?
IfPayload is a small value (under ~1KB) passed between tasks
UseXCom — URIs, counts, flags, config
IfPayload is a file, DataFrame, or anything bigger
UseObject storage, and push only the URI
IfArtifact is intermediate state used by one task only
UseTask-local /tmp is fine — it dies with the task
IfArtifact must survive reruns of the same interval
UseObject storage, keyed by data_interval_end
IfData must be queryable by the warehouse team
UseObject storage in raw/staging layout, exposed as an external table
IfValue must persist for weeks and be auditable
UseObject storage with lifecycle rules — never the metadata DB

6. Cost and Lifecycle of Staged Objects

Object storage is cheap until it isn't. Standard-tier S3 runs roughly $0.023 per GB per month, so a 500GB staging prefix is about $11.50 a month — rounding error. But every nightly run adds objects, and without lifecycle rules the staging bucket grows forever: 500GB becomes 5TB, then 50TB, and now it's a real line on the bill.

Lifecycle rules are the answer, and they're boring to set up and critical to keep. Expire raw/ and staging/ prefixes after a few days, move cold partitions to cheaper tiers, and never delete clean/ data that warehouses still reference without a documented retention decision.

One trap: cleanup tasks in the DAG, not just lifecycle rules. A cleanup task that deletes objects referenced by an active run creates exactly the nondeterminism you removed. Prefer lifecycle rules on prefixes for automatic expiry, and keep a DAG-side cleanup only for explicitly temporary artifacts.

⚠ No lifecycle policy = unbounded bill
Staging objects compound daily. Set lifecycle rules on the day you create the bucket — expiry on raw/ and staging/, retention decisions on clean/.
📊 Production Insight
Standard-tier storage is cheap at GB scale, compounding at TB scale.
Lifecycle rules expire what reruns don't need.
DAG cleanup tasks must never delete live run inputs.
🎯 Key Takeaway
Staging cost compounds nightly — set lifecycle rules early.
Expire raw and staging prefixes automatically.
Never let cleanup tasks race a running DAG.
Staging Cost Compounds Nightly Staging Cost Compounds Nightly Cheap at 500GB, a real bill at 50TB Standard tier ≈ $0.023 / GB / month 500GB ≈ $11.50 — rounding error Every nightly run adds objects the bucket grows while nobody looks No lifecycle rules — grows forever 500GB → 5TB → 50TB Expire raw/ and staging/ prefixes tier cold data, keep clean/ per contract DAG cleanup never deletes live inputs deleting live run inputs breaks determinism THECODEFORGE.IO
thecodeforge.io
Airflow Object Storage

7. The Migration Playbook: From /tmp to Object Storage

The migration is mechanical, which is the point — determinism is a refactor, not an act of faith. Step one: inventory every open(), write, and file path in the codebase. Step two: replace file I/O with ObjectStoragePath operations against a staging bucket. Step three: key every artifact by data_interval_end. Step four: change XCom payloads to URIs. Step five: prove it.

Proof is the double-run: clear a data interval and rerun it twice. Row counts must match exactly, artifact keys must match exactly, and the second run must read the objects the first run wrote. If anything differs, the pipeline still has hidden local or time-keyed state.

Then delete the /tmp habits. Add a lint rule that flags open() in task code, and review every new DAG for URI-only artifact passing. The discipline is what survives, not the migration.

📊 Production Insight
Migrate in five mechanical steps, from inventory to proof.
The double-run test is the acceptance criterion.
Lint against open() to keep the discipline alive.
🎯 Key Takeaway
Inventory paths, swap to URIs, key by interval.
Prove with a cleared-interval double run.
Make URI-only artifact passing the review rule.
● Production incidentPOST-MORTEMseverity: high

The /tmp That Ran Twice, Differently

Symptom
A nightly aggregation DAG produced different row counts on reruns of the same data interval, and the delta between dev and prod widened every week.
Assumption
The team assumed each task wrote to a shared temp directory, the way it had on the old single-machine scheduler.
Root cause
Tasks wrote intermediate CSVs to /tmp on their local worker. With multiple workers the file was different on every machine, sometimes missing entirely, and the transform task silently recreated it from partial data — outputs varied per run.
Fix
Moved all intermediate artifacts to object storage with deterministic keys keyed by data_interval, and passed URIs — not file paths — between tasks. Reruns of the same interval became byte-identical.
Key lesson
  • Local filesystem paths are not shared state — on a distributed executor they don't exist outside the worker that wrote them.
  • Nondeterministic output is often a storage problem wearing a data problem costume.
  • Key staged objects by data_interval so reruns read exactly what the original run wrote.
  • Pass URIs through XCom, never the payload — it keeps both the DB and the reruns healthy.
Production debug guideSymptom to Action5 entries
Symptom · 01
Task says 'file not found' on one run, succeeds on another
Fix
The file was written to a different worker's local disk. Search the DAG for open() and /tmp paths and replace them with object URIs.
Symptom · 02
Reruns produce different row counts
Fix
Check whether the task reads a fixed key or a run-dependent key. Fixed keys get overwritten by other runs; key by data_interval_end.
Symptom · 03
'XCom value too large' errors
Fix
Some task is pushing payloads through XCom. Refactor to write the artifact to object storage and push only the URI.
Symptom · 04
Permission errors only in production
Fix
Workers use different IAM roles or credentials. Test the object storage connection from a worker shell, then audit the secret backend.
Symptom · 05
Object storage bill climbing
Fix
No lifecycle policy on staged files. Review bucket lifecycle rules and add expiration for the staging prefix.
★ Object Storage Quick Debug Cheat SheetFirst-response commands for artifact failures.
File not found on rerun
Immediate action
Check which worker wrote the file
Commands
docker compose logs worker --tail 200 | grep -i /tmp
aws s3 ls s3://airflow-artifacts-prod/raw/ --recursive | tail -20
Fix now
Move the artifact to object storage and reference it by URI.
XCom too large+
Immediate action
Find the pushing task
Commands
SELECT dag_id, task_id, key, length(value) AS bytes FROM xcom WHERE length(value) > 10000;
aws s3 cp s3://airflow-artifacts-prod/staging/feed_2026-07-31.parquet -
Fix now
Write the payload to S3 and pass the URI in XCom instead.
S3 permission denied in prod+
Immediate action
Test the connection from a worker
Commands
airflow connections get aws_default
docker compose exec worker aws s3 ls s3://airflow-artifacts-prod/
Fix now
Rotate keys in the secrets backend; verify the worker IAM role policy.
Staging bucket growing unbounded+
Immediate action
Summarize bucket size
Commands
aws s3 ls --summarize s3://airflow-artifacts-prod/staging/ | tail -5
aws s3api get-bucket-lifecycle-configuration --bucket airflow-artifacts-prod
Fix now
Add lifecycle rules to expire staging prefixes after a few days.
Task Artifact Storage
AspectLocal /tmpObject storage (S3/GCS)
Worker affinityDied with the workerShared across workers
DeterminismRace-prone on multi-workerStable per run
Retry safetyFiles lost on retryObjects persist
XCom size reliefNoneURIs instead of payloads
CleanupManual, forgottenLifecycle policies
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
broken_tmp_pattern.pyfrom airflow.decorators import task1. Local Filesystem Traps on Distributed Workers
stage_with_object_storage.pyfrom airflow.decorators import task3. Airflow 3 Object Storage Abstractions
three_phase_dag.pyfrom airflow import DAG4. Staging Patterns

Key takeaways

1
Local /tmp is worker-private
treat it as task-private scratch, never shared state.
2
Object storage with deterministic, interval-keyed URIs makes reruns reproducible.
3
Pass URIs through XCom; keep payloads out of the metadata DB.
4
Airflow 3's ObjectStoragePath gives you one API across S3, GCS, and Azure.
5
Add lifecycle rules and cleanup policies the day you create the bucket.

Common mistakes to avoid

4 patterns
×

Writing task artifacts to local /tmp

Symptom
Same DAG produces different outputs per run and per worker
Fix
Write to object storage with deterministic keys and pass URIs between tasks.
×

Pushing payloads through XCom

Symptom
XCom value too large errors; metadata DB slows down
Fix
Store the artifact in object storage and push only the URI.
×

Hardcoding run-specific paths

Symptom
Reruns overwrite or miss each other's objects; outputs collide
Fix
Key artifacts by data_interval_end so each run has its own, stable keys.
×

No lifecycle rules on staging buckets

Symptom
Object storage bill climbs every month without review
Fix
Add lifecycle rules: expire raw/ and staging/ prefixes, tier cold data.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why can't two Airflow tasks share a file on /tmp?
Q02SENIOR
How do you pass a large DataFrame between tasks without XCom?
Q03SENIOR
Design the artifact layout and lifecycle for a multi-worker ETL that mus...
Q01 of 03JUNIOR

Why can't two Airflow tasks share a file on /tmp?

ANSWER
Each task runs on whichever worker has a free slot, and every worker has its own local filesystem. A file written to /tmp exists only on that worker and dies with it. With retries landing on different workers, even the same task can't rely on its own earlier writes. Shared state must live outside the workers — in object storage, addressed by URI.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Why is /tmp unreliable in Airflow?
02
What exactly is Airflow object storage?
03
Can I still use XComs for large data?
04
How do I key artifacts so reruns are reproducible?
05
What does object storage cost and how do I control it?
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
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 Deferrable Operators
34 / 37 · Airflow
Next
Airflow HITL Approval