Airflow Deferrable Operators: Sensors Without Worker Waste
Airflow deferrable operators free worker slots while sensors wait.
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
- ✓Working Airflow 2.9+ or Airflow 3 environment with the triggerer enabled.
- ✓Solid understanding of sensors and poke vs reschedule modes.
- ✓Comfort with the executor and worker slot model (parallelism, queues).
- Airflow deferrable operators pause a task mid-run, release its worker slot, and resume when an external event fires.
- Key pieces: the task defers, the triggerer process watches asynchronously, and a trigger event resumes the task instance.
- A 40-minute poke sensor holds a worker slot the whole wait; a deferrable equivalent holds it for milliseconds, so the cost drops to ~0.
- The triggerer is a separate service — run and scale it like the scheduler, or deferred tasks stay stuck forever.
- Keep timeouts honest: the clock keeps running while a task is deferred, so align the timeout with the business wait.
- Deferral only works from class-based operators — a PythonOperator or TaskFlow callable can't defer, so wrap the wait in a custom operator class.
Think of a waiter holding one table for a party that hasn't arrived. The table (worker slot) sits empty while the waiter keeps checking the door every 30 seconds. A deferrable operator is the waiter who leaves a note with the host, gets back to serving the other tables, and only returns when the party actually walks in. Same job, no table burned.
In production, sensors are the quiet budget leak nobody watches. A FileSensor waiting for a 40-minute ETL to drop a file doesn't just wait — it holds a full worker slot for the entire 40 minutes, a slot a real task could be using. Multiply that by 50 sensors and you've effectively shrunk your cluster by 50 workers you're paying for but not using.
The old fixes were workarounds. mode="reschedule" freed the slot but hammered the scheduler with re-enqueue events. Airflow's real answer is deferrable operators: the task parks itself in the metadata DB, a lightweight triggerer process watches asynchronously, and a trigger event wakes the task exactly when the file lands.
The triggerer runs on asyncio, so one process can babysit tens of thousands of pending waits on a few hundred megabytes of RAM.
This is the upgrade that turns sensors from a tax on your cluster into a rounding error. If you're still poking in 2026, you're paying a worker salary for a task that does nothing.
1. The Poke Problem: Worker Slot Economics
An executor owns a fixed number of worker slots — parallelism on LocalExecutor, concurrency on Celery, pods on KubernetesExecutor. Every running task occupies one slot for as long as it runs. The core economics lesson: a slot is a scarce resource, and a waiting task is still a running task.
A poke-mode sensor is the worst citizen in that economy. It occupies a slot, wakes up every poke_interval seconds to check a condition, sleeps, and repeats — for the entire timeout. If the wait is 40 minutes and the file lands at minute 39, you've spent 39 minutes of worker time on a task that did one check's worth of work.
Meanwhile, real tasks sit in the queue. Queue depth grows, SLAs slip, and the knee-jerk fix is to add workers. That's buying capacity to cover a waste problem.
2. The Deferrable Model: Defer and Resume
Deferrable operators change the contract between a task and the executor. Instead of occupying a slot and poking, the task runs for a moment, then calls — it serializes a trigger description into the metadata DB and releases the worker slot. The task instance enters the self.defer()deferred state.
The trigger object knows how to wait. When the condition is met — the file appears, the API returns 200, the partition shows up — it fires a trigger event. The triggerer hands that event to the scheduler, which re-queues the task. The task resumes where it left off, exactly where execute_complete picks up.
Two things make this work: a defer call that's cheap (milliseconds, not minutes), and a resume path that's deterministic. The task doesn't re-run from the top — it resumes. That's the crucial difference from a retry, which restarts the whole task instance.
Two boundaries from the official docs. First, deferred tasks stop counting against pool slots by default — Airflow exempts them from pool limits unless you opt them back in per pool. Second, deferral is only available in class-based operators; a plain PythonOperator or a TaskFlow @task callable cannot defer, so a wait inside a callable has to be moved into an operator class or split out into a custom trigger-based operator.
- Poke mode: the task blocks a worker for the entire wait.
- Reschedule mode: the task releases the slot but re-enqueues on every poke.
- Defer mode: the task yields control to the triggerer and takes it back exactly once.
3. The Triggerer Process and Its Scaling
The triggerer is the engine room of deferrable execution. It's a separate Airflow component — airflow triggerer — that runs trigger objects on an asyncio event loop. One triggerer process handles thousands of deferred triggers with a handful of threads, because waiting on file checks and API polls is I/O, not CPU.
Scaling the triggerer is boring in the best way. Default capacity is 1,000 concurrent triggers per process; run multiple triggerer replicas for headroom and high availability, exactly like you run multiple schedulers. If a triggerer dies, its triggers survive in the metadata DB and get picked up when it restarts.
The operational trap is the opposite of scaling: not running one at all. Teams deploy webserver, scheduler, and workers, forget the triggerer, and then wonder why deferred tasks never resume. The UI shows deferred; the triggerer log is empty; nothing moves.
HA is real, though: triggerers heartbeat to the metadata DB, and when one dies or is partitioned away, the surviving triggerers re-adopt its triggers after a ~30-second grace period — no manual restart needed. Airflow also de-duplicates events fired while a trigger briefly runs on two hosts during a partition, so double-execution is invisible to your operators.
4. Converting Sensors with deferrable=True
Most of the sensor catalog is deferrable-ready. FileSensor, ExternalTaskSensor, S3KeySensor, TimeSensor, and the cloud provider sensors accept deferrable=True and internally swap their poke loop for a trigger. In Airflow 3 the trigger machinery is the default path for new sensor types.
Conversion is a two-line change: set deferrable=True and keep a sane poke_interval (it becomes the trigger's poll rate) and timeout. Then verify the resume path, not just the defer path. Run the DAG with a file that arrives late and confirm the task completes after the file lands.
The trap: a sensor that defers but whose trigger can't serialize. Triggers must be reconstructible from constructor args — a trigger holding an open socket or a live client object fails serialization at defer time and the task errors immediately.
One config lever to know: [operators] default_deferrable flips the default for every operator and sensor that supports both modes, so deferrable=True stops being a per-task decision on big fleets.
5. Cost Math: 40 Minutes of Poke vs Milliseconds of Defer
Put numbers on it. A poke-mode sensor with a 40-minute timeout burns 2,400 worker-seconds of capacity, whether or not the file arrives. The same task deferrable holds the slot for the milliseconds it takes to serialize the trigger — call it 4 orders of magnitude cheaper, and the worker is free to run real work the whole wait.
Reschedule mode sits between the two: the slot is freed, but every poke re-enqueues the task through the scheduler and writes state transitions to the metadata DB. With a 30-second poke_interval, a 40-minute wait generates ~80 re-enqueues and scheduler wakeups. Deferrable mode generates exactly two: one defer, one resume.
That's why the triggerer wins at scale. Ten thousand deferred waits across a fleet cost one triggerer process ~10MB of RAM each thousand, versus ten thousand poke cycles hammering workers and DB. The economics invert completely as the wait count grows.
One caveat from the docs: at the opposite end of the spectrum — many short waits inside a single task — Airflow 3.2+'s async Python operators multiplex concurrent I/O within one worker slot and beat repeated defer/resume cycles, whose stop-and-restart overhead adds up when deferrals are frequent. Rule of thumb: few, long waits → defer; many, short waits → async operator.
6. Writing a Custom Deferrable Operator
When no provider trigger exists for your wait — a bespoke API, an internal system, a multi-condition check — you write your own. A custom deferrable operator is two classes: the operator that defers, and the trigger that waits. The trigger's is an async generator: check, sleep, check, and run()yield TriggerEvent the moment the condition is met.
The operator keeps `execute()` minimal: do any pre-check, then self.defer(trigger=..., method_name="execute_complete"). Airflow serializes the trigger, parks the task, and later calls execute_complete with the event payload on resume. Keep everything in — class path plus constructor kwargs — or deferral breaks in distributed setups.serialize()
Two production additions from the docs. on the trigger is the safe place to cancel external work (a BigQuery job, a Databricks run): Airflow calls it when a user clears, fails, or marks the task done, but never on triggerer restart or redistribution — so nothing in-flight gets cancelled during a rolling deploy. And since 2.10, on_kill()start_from_trigger (with start_trigger_args) and end_from_trigger let the task skip the worker entirely, deferring straight to the triggerer and even finishing there without renting a worker slot for the resume.
run().serialize() round-trips before deploying.7. Timeouts and Resume Semantics
The timeout clock doesn't pause for politeness. A task with timeout=2400 that defers at minute 0 and gets no trigger event fails at minute 40 — the wait was supposed to be inside the timeout, not outside it. Size task timeouts to the full business wait plus a buffer, or split long waits across multiple tasks.
Failure paths matter as much as success. A trigger that never fires eventually times the task out; the DAG fails, retries apply from the start of the task instance, and the retry defers again. A trigger that raises — bad credentials, deleted path — fails the task immediately. soft_fail=True converts timeout failures into skips for cases where a missing file is a legitimate outcome.
One more trap: clearing a task that's currently deferred. The old trigger keeps running until it fires or dies, so clear the DAG run, not just the task, and expect the trigger's garbage collection to lag by a poll interval.
The 40-Minute Worker Slot
- A waiting task is not a free task — poke-mode sensors hold a worker slot for the whole wait.
- Queue depth is the symptom; the cause is often slot-hogging waits, not insufficient workers.
- Deferring without a running triggerer leaves tasks stuck in deferred state — the triggerer is the missing piece.
- Measure cost as worker-hours per wait, not CPU used — and deferrable operators win by four orders of magnitude.
docker compose ps triggerer or the triggerer pod.docker compose logs triggerer --tail 100airflow dags list-import-errors| File | Command / Code | Purpose |
|---|---|---|
| wait_for_partition.py | from datetime import datetime | 1. The Poke Problem |
| deferrable_sensor.py | from datetime import datetime | 2. The Deferrable Model |
| custom_partition_trigger.py | from airflow.sensors.base import BaseSensorOperator | 6. Writing a Custom Deferrable Operator |
Key takeaways
Common mistakes to avoid
4 patternsLeaving long waits in poke mode
Deploying deferrable operators without a triggerer
Timeout shorter than the business wait
Sub-second poke_interval on high-volume DAGs
Interview Questions on This Topic
What is the difference between a regular sensor and a deferrable operator?
defer() to serialize a trigger into the metadata DB, releases the slot, and is resumed by a trigger event fired by the triggerer process. Same outcome, radically different resource cost.Frequently Asked Questions
20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.
That's Airflow. Mark it forged?
5 min read · try the examples if you haven't