Home DevOps Airflow Deferrable Operators: Sensors Without Worker Waste
Advanced 5 min · August 1, 2026

Airflow Deferrable Operators: Sensors Without Worker Waste

Airflow deferrable operators free worker slots while sensors wait.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Production
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 15-20 min read
  • 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).
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer
  • 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.
✦ Definition~90s read
What is Airflow Deferrable Operators?

Airflow deferrable operators are tasks that release their worker slot while waiting for an external event, letting a lightweight triggerer process watch asynchronously and resume the task the moment the event fires.

Think of a waiter holding one table for a party that hasn't arrived.
Plain-English First

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.

wait_for_partition.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
from datetime import datetime

from airflow import DAG
from airflow.providers.filesystem.sensors.filesystem import FileSensor

with DAG(
    dag_id="partner_feed_ingest",
    schedule="0 2 * * *",
    start_date=datetime(2026, 1, 1),
    catchup=False,
) as dag:
    wait_for_feed = FileSensor(
        task_id="wait_for_feed",
        filepath="/opt/data/partners/feed_{{ ds }}.csv",
        poke_interval=30,
        timeout=2400,  # 40 minutes of poking
        mode="poke",
    )
Output
Grid view: wait_for_feed running for 40 min · 90 tasks queued behind it
⚠ A slot is a slot
The scheduler doesn't know your sensor is 'just waiting'. It sees a running task. Every minute of poke is a minute of worker capacity that could be running your nightly load.
📊 Production Insight
Watch queue depth as your first sensor-cost signal.
A spike that tracks your sensor DAG's window is slot waste.
Audit poke-mode sensors before buying more workers.
🎯 Key Takeaway
Worker slots are the scarce resource in Airflow.
A waiting poke sensor holds one for the full wait.
Treat sensor waits as a capacity problem, not a scheduling one.
Worker slot economics: the poke problem Worker Slot Economics: Poke vs Real Work 40-minute wait, 90 tasks queued behind Executor — fixed worker slots Poke sensor 40-minute wait Real task running Real task running Real task running Queue: 90 tasks waiting blocked behind the sensor A waiting task is a running task THECODEFORGE.IO
thecodeforge.io
Airflow Deferrable Operators

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 self.defer() — it serializes a trigger description into the metadata DB and releases the worker slot. The task instance enters the 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.

deferrable_sensor.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
from datetime import datetime

from airflow import DAG
from airflow.providers.filesystem.sensors.filesystem import FileSensor

with DAG(
    dag_id="partner_feed_ingest",
    schedule="0 2 * * *",
    start_date=datetime(2026, 1, 1),
    catchup=False,
) as dag:
    wait_for_feed = FileSensor(
        task_id="wait_for_feed",
        filepath="/opt/data/partners/feed_{{ ds }}.csv",
        poke_interval=30,
        timeout=2400,
        mode="reschedule",
        deferrable=True,
    )
Mental Model
Yielding, not sleeping
A deferrable task is a generator, not a while-loop.
  • 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.
📊 Production Insight
Defer is not a retry: the task resumes, it doesn't restart.
Resume must be deterministic from the trigger event.
Test the resume path before trusting the deferral.
🎯 Key Takeaway
Deferral parks the task and releases the slot.
The triggerer watches; the task resumes on event.
Deferred tasks cost ~nothing while waiting.
Defer and resume: the deferrable contract Defer and Resume: The Deferrable Contract Defer to metadata DB, resume on event Task runs briefly then yields control self.defer() Trigger serialized metadata DB, state=deferred triggerer picks it up Triggerer watches asyncio, waits for the file condition met Scheduler re-queues task instance wakes resume path Resume: execute_complete continues where it left off The task resumes — it never restarts THECODEFORGE.IO
thecodeforge.io
Airflow Deferrable Operators

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.

⚠ Defer without triggerer = permanent pause
Deferred tasks only resume when a triggerer process exists to run their triggers. Add the triggerer to your deploy manifest in the same commit that adds deferrable operators.
📊 Production Insight
One triggerer handles ~1,000 deferred triggers by default.
Run replicas for HA; triggers survive restarts.
Missing triggerer is the #1 deferrable outage — check it first.
🎯 Key Takeaway
The triggerer is a first-class Airflow service.
It runs triggers asynchronously on an event loop.
Ship it in the same manifest as the scheduler.
The triggerer process and its scaling The Triggerer: Engine Room of Deferred Tasks One process, thousands of triggers Deferred triggers waiting in metadata DB Triggerer process asyncio event loop Trigger events task resumes ~1,000 triggers per process run replicas for headroom + HA Triggers survive restarts picked up when triggerer returns No triggerer = permanent pause deferred tasks never resume THECODEFORGE.IO
thecodeforge.io
Airflow Deferrable 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.

📊 Production Insight
Built-in sensors convert with deferrable=True and sane poke_interval.
Always test the resume path, not just the defer.
Serialization failures surface at defer time — keep triggers arg-only.
🎯 Key Takeaway
Most sensors become deferrable with two lines.
Test the resume path; serialize triggers arg-only.
Airflow 3 defaults new sensors to the trigger machinery.

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.

cost_math.pyPYTHON
1
2
3
4
5
6
7
8
# One 40-minute wait, per mode
# poke:      1 worker slot x 2,400 s = 2,400 worker-seconds
# reschedule: ~80 re-enqueues, 0 worker-seconds, 80 scheduler events
# deferrable: ~40 ms of triggerer time, 0 worker-seconds
#
# 50 such waits on an 8-slot cluster:
# poke -> 50 slots wanted, 8 exist: the other 42 queue for 40 min
# deferrable -> 0 slots wanted; all 8 workers stay busy on real work
📊 Production Insight
Poke: 2,400 worker-seconds per 40-minute wait.
Reschedule: ~80 scheduler events per wait.
Deferrable: two events total, ~0 worker cost.
🎯 Key Takeaway
Deferrable waits cost ~4 orders of magnitude less.
Reschedule trades slots for scheduler churn.
At fleet scale, only the triggerer model holds.

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 run() is an async generator: check, sleep, check, and 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 serialize() — class path plus constructor kwargs — or deferral breaks in distributed setups.

Two production additions from the docs. on_kill() 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, 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.

custom_partition_trigger.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
40
41
42
import asyncio

from airflow.sensors.base import BaseSensorOperator
from airflow.triggers.base import BaseTrigger, TriggerEvent


class PartitionReadyTrigger(BaseTrigger):
    def __init__(self, partition_path: str, poke_interval: float = 60.0):
        super().__init__()
        self.partition_path = partition_path
        self.poke_interval = poke_interval

    def serialize(self):
        return (
            "market_plugins.triggers.PartitionReadyTrigger",
            {"partition_path": self.partition_path, "poke_interval": self.poke_interval},
        )

    async def run(self):
        while True:
            if await self._partition_exists():
                yield TriggerEvent({"status": "ready"})
                return
            await asyncio.sleep(self.poke_interval)


class WaitForPartitionOperator(BaseSensorOperator):
    def __init__(self, partition_path: str, poke_interval: float = 60.0, **kwargs):
        super().__init__(**kwargs)
        self.partition_path = partition_path
        self.poke_interval = poke_interval

    def execute(self, context):
        self.defer(
            trigger=PartitionReadyTrigger(self.partition_path, self.poke_interval),
            method_name="execute_complete",
        )

    def execute_complete(self, context, event=None):
        if event is None:
            raise AirflowException("Partition never became ready")
        self.log.info("Partition ready: %s", self.partition_path)
Output
Defer at 02:00:00 → resume at 02:38:00 (event: {'status': 'ready'}) → SUCCESS
📊 Production Insight
Operator defers; trigger waits; resume completes.
Keep triggers reconstructible from constructor args.
Put triggers in a plugin package, not in the DAG file.
🎯 Key Takeaway
Custom deferrable = operator + trigger pair.
Triggers yield TriggerEvent from async run().
Test 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.

📊 Production Insight
Timeout clocks run through deferrals — size to the wait.
Retries restart the task instance and defer again.
soft_fail turns timeout into skip for legitimate absences.
🎯 Key Takeaway
Timeouts must cover the full business wait.
Fail-fast on trigger errors; timeout on silence.
Use soft_fail when a missing condition is valid.
● Production incidentPOST-MORTEMseverity: high

The 40-Minute Worker Slot

Symptom
Queue depth climbed to 90 pending tasks every night at the same time; the DAG that waited on a partner feed for 40 minutes blocked every downstream run behind it.
Assumption
The team assumed waiting sensors were cheap — the task was 'just sleeping', so it couldn't be consuming resources.
Root cause
A long-polling sensor in poke mode held a worker slot for the entire wait. The executor had no free slots left, so real tasks queued behind a task that was doing nothing at all.
Fix
Converted the sensor to a deferrable operator with deferrable=True, ran a dedicated triggerer, and watched queue depth drop to near zero. The 40-minute wait now costs milliseconds of worker time.
Key lesson
  • 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.
Production debug guideSymptom to Action5 entries
Symptom · 01
Tasks stuck in deferred state for hours
Fix
Verify the triggerer process is running and healthy — deferred tasks never resume without it. Check docker compose ps triggerer or the triggerer pod.
Symptom · 02
Deferred task fails with a timeout right after deferring
Fix
Compare the task's timeout with the external wait window. The timeout starts at task start, not at resume; extend it or split the wait.
Symptom · 03
High scheduler load after switching to reschedule mode
Fix
Reschedule mode re-enqueues the task on every poke, generating scheduler events. Prefer deferrable mode, which parks the task instead.
Symptom · 04
Triggerer CPU pinned at 100%
Fix
Count deferred tasks and their poll rates. Too many sub-second trigger intervals saturate the event loop; batch or slow the checks.
Symptom · 05
Worker slots free but queue still deep
Fix
Look for sensors still in poke mode or long-running tasks holding slots. Filter the Grid view by state and check the executor's running-versus-queued split.
★ Deferrable Operators Quick Debug Cheat SheetFirst-response commands when deferred tasks misbehave.
Task stuck in deferred state
Immediate action
Confirm the triggerer is up
Commands
docker compose logs triggerer --tail 100
airflow dags list-import-errors
Fix now
Start or restart the triggerer service, then clear the stuck task instance.
Resume never fires+
Immediate action
Check trigger event history in the UI (Browse > Task Instances, filter state=deferred)
Commands
airflow tasks list market_etl
kubectl logs deployment/airflow-triggerer --tail 50
Fix now
Ensure the trigger's timeout covers the full business wait; then re-queue the task.
High triggerer memory+
Immediate action
Count deferred tasks
Commands
SELECT count(*) FROM task_instance WHERE state = 'deferred';
airflow config get-value triggerer default_capacity
Fix now
Scale triggerer replicas or raise default_capacity.
Sensor still poking after deferrable=True+
Immediate action
Confirm the new code is deployed and parsed
Commands
airflow dags list
airflow dags test wait_for_partition 2026-07-31
Fix now
Check the Grid view for the run's task code version; redeploy and rerun.
Poke vs Reschedule vs Deferrable
AspectPoke modeReschedule modeDeferrable mode
Worker slot during waitHeld for the full waitReleased between pokesReleased for the full wait
Metadata DB impactNone during waitA state transition per pokeOne defer event, one resume event
Scheduler loadLowHigh — every poke re-enqueuesLow — the triggerer watches, not the scheduler
Cost of a 40-min wait2,400 worker-seconds~80 re-enqueues, ~0 worker-seconds~0 worker-seconds, ~40 ms of triggerer time
Best forSub-minute waitsWaits without an async trigger availableWaits over a few minutes, in bulk
Needs triggerer processNoNoYes
⚙ Quick Reference
3 commands from this guide
FileCommand / CodePurpose
wait_for_partition.pyfrom datetime import datetime1. The Poke Problem
deferrable_sensor.pyfrom datetime import datetime2. The Deferrable Model
custom_partition_trigger.pyfrom airflow.sensors.base import BaseSensorOperator6. Writing a Custom Deferrable Operator

Key takeaways

1
Deferrable operators free worker slots entirely while a task waits; poke mode holds them.
2
The triggerer is a first-class service
run it, scale it, and monitor its heartbeat.
3
Timeouts keep running while a task is deferred; size them to the business wait, not the code.
4
One triggerer process handles thousands of deferred tasks on asyncio
it's cheap at fleet scale.
5
Convert sensors with deferrable=True where the provider supports it, then verify the resume path.

Common mistakes to avoid

4 patterns
×

Leaving long waits in poke mode

Symptom
Queue depth spikes every night while sensors occupy worker slots
Fix
Convert to deferrable=True where a trigger exists, or reschedule mode as a stopgap.
×

Deploying deferrable operators without a triggerer

Symptom
Tasks enter deferred state and never resume — the pipeline quietly stalls
Fix
Ship the triggerer service in the same manifest as scheduler and workers.
×

Timeout shorter than the business wait

Symptom
Deferred tasks fail with timeout before the external event arrives
Fix
Size timeout to the full wait plus buffer; split very long waits into chained tasks.
×

Sub-second poke_interval on high-volume DAGs

Symptom
Triggerer CPU pinned; scheduler and DB churn from re-enqueues
Fix
Use deferrable mode with a 30-60s poll rate, or reschedule mode with a sane interval.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
What is the difference between a regular sensor and a deferrable operato...
Q02SENIOR
Walk through what happens when a deferrable task defers and then resumes...
Q03SENIOR
A team has 200 sensors in poke mode. How do you size the migration and t...
Q01 of 03JUNIOR

What is the difference between a regular sensor and a deferrable operator?

ANSWER
A regular poke-mode sensor occupies a worker slot for the entire wait and polls on an interval. A deferrable operator runs briefly, calls 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.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
What is the difference between a deferrable operator and a sensor in reschedule mode?
02
Do I need extra infrastructure for deferrable operators?
03
Can every sensor be made deferrable?
04
What happens if the triggerer is down?
05
Does deferrable mode change retry behavior?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Lessons pulled from things that broke in production.

Follow
Verified
production tested
August 01, 2026
last updated
1,750
articles · all by Naren
🔥

That's Airflow. Mark it forged?

5 min read · try the examples if you haven't

Previous
Airflow Data Quality
33 / 37 · Airflow
Next
Airflow Object Storage