Airflow Task State Store (AIP-103)
Airflow 3.3 ships two key/value stores and a crash-safety mixin for operators that submit external jobs.
task_state_store,asset_state_store, andResumableJobMixin's crash-safety guarantee require Airflow 3.3+. Check first:Below 3.3:
task_state_store/asset_state_storeare unavailable, anddurable=Trueis a no-op — provider operators ship a pre-3.3ResumableJobMixinshim that always submits fresh (see Section 5). Tell the user those specific features aren't available yet and link the AIP-103 tracking issue. This does not gate Section 6's Triggerer-vs-mode="reschedule"decision, or the general "green submit ≠ success" anti-pattern — those apply on any Airflow version. On a pre-3.3 DAG, give that guidance in full; only drop the "durable=Trueadds crash-safety" half of it.
Section 1 — Pick the right primitive
When NOT to use these:
- Passing data between tasks -> use XCom
- Large payloads (model weights, dataframes) -> use XCom with an object storage backend
- Config or secrets shared across DAGs -> use Variables or Connections
Section 2 — Detect anti-patterns in existing DAGs (on demand)
When the user asks to review a DAG or asks "is there a better way", scan for these patterns and flag them:
Show a before/after snippet when flagging. Use the canonical examples in Steps 3–5 as the "after".
The submit+sensor split is worth a comment even when the sensor code is bug-free — reviewing the sensor's code quality (correct mode=, correct terminal-state handling, cached hook) is a separate question from whether the two-task split is the right architecture. Follow Section 6, "Submit-and-poll DAGs: one task or two?" for that decision; don't re-derive it here.
Section 3 — task_state_store: per-task coordination state
task_state_store is a key/value store scoped to a single task instance identity (dag_id + run_id + task_id + map_index). It survives retries — a new retry on the same task reads the same store.
API:
Key rules:
- Values must be JSON-serializable (
str,int,float,bool,list,dict—Nonevalues are rejected). - Default expiry is controlled by
[state_store] default_retention_days(0 = never expire). - Use
NEVER_EXPIREfor keys that must outlive the default retention window (e.g. a job ID for a multi-day Spark job). - Max value size defaults to 64 KB; configurable via
[state_store] max_value_storage_bytes(0 = no limit). For larger payloads, configure a custom[state_store] backendor a worker side backend configured via:[workers] state_store_backend.
Mapped tasks — each index has its own namespace:
When a task is dynamically mapped (task.expand(...)), each map index gets an isolated task_state_store scoped to its own map_index. Indices do not share state.
clear() clears only the current index. To wipe state across all map indices of a task group, use the CLI or core API.
Before (anti-pattern):
After:
Section 4 — asset_state_store: per-asset metadata across DAG runs
asset_state_store is scoped to an asset, not a task instance. It persists across DAG runs — the same key on the same asset is readable and writable by any task that produces or consumes it.
Reading the store from a consumer DAG:
Key rules:
asset_state_storeis injected by Airflow as a named kwarg — declare it asdef my_task(asset_state_store=None). Do NOT combine with**context; Airflow injects it separately.- Use
datetime.now(tz=timezone.utc).isoformat()for timestamps — neverdatetime.utcnow()(not timezone-aware). - Same JSON-serializable value constraint as
task_state_store. - No per-key expiry — asset state store entries have no TTL (the asset outlives any single run).
- Readable by any DAG that declares the asset as an inlet or outlet.
Mapped tasks — last writer wins:
asset_state_store is scoped to the asset, not the map index. If multiple mapped indices write the same key concurrently, the last write wins. Use distinct keys per index or ensure only one index writes to a given key.
Before (anti-pattern):
After:
Section 5 — ResumableJobMixin: crash-safe external job submission
Use whenever a task submits a job to an external system (Spark, Databricks, dbt Cloud, AWS Batch, etc.) and could resubmit a duplicate on retry — whether that same task also polls for completion inside one execute() call, or hands the job id to a separate downstream poll/sensor task. Without this mixin (or a provider operator that already builds it in, like DatabricksSubmitRunOperator's durable=True default), a worker crash after submission means the next retry of the submit task resubmits a duplicate job — that risk exists regardless of whether polling happens in that same task or a separate one.
Scope check before recommending it: if submit and poll are already two separate tasks (e.g. a submit task handing a job id to a downstream sensor/poll task via XCom), the poll/sensor task doesn't need this mixin — it never submits anything, so retrying it is already safe. The submit task still does; splitting off the poll step doesn't make the submit task's own duplicate-submission risk go away. See the table row below.
When NOT to use ResumableJobMixin:
ResumableJobMixin holds the worker slot for the full polling duration — the same as a standard synchronous operator. The benefit is crash safety and job continuity, not resource efficiency.
Opting out of crash recovery:
The mixin ships with durable=True by default. Set durable=False to skip all task_state_store interaction and run a plain submit/poll/result cycle — useful in test environments or when the external system has its own dedup:
Implementing the mixin
What happens on retry
external_id_key warning
Never rename
external_id_keyon an operator that is already deployed with in-flight task instances. The old key is stored intask_state_storeunder the previous name. A rename makes the mixin treat every active retry as a fresh submission, defeating the crash-safety guarantee.
Before (anti-pattern):
After:
For the "submit + separate sensor task" DAG shape specifically — whether to collapse it to one task — see Section 6, "Submit-and-poll DAGs: one task or two?". That's an architecture call driven by Triggerer availability and job duration. It's a different question from whether the submit task itself needs durable=True for crash-safety (usually yes, whether or not the split stays) — see the table row above.
Section 6 — Submit-and-poll DAGs: one task or two?
A common DAG shape for Databricks/Snowflake/BigQuery/Redshift/Spark jobs: a submit task that fires the job with wait_for_termination=False (or equivalent) and returns immediately, followed by a hand-rolled sensor task that polls the same job to completion. The submit task going green the instant the external system accepts the job (not when it finishes) is always worth pointing out — a retry, alert, or SLA on the submit task alone tells you nothing about real job success, only the sensor task's outcome does. But whether the split itself should be removed depends on Triggerer availability:
If a Triggerer is deployed — collapse to one task with deferrable=True and set wait_for_termination=True explicitly. deferrable=True does not by itself imply wait_for_termination=True — on DatabricksSubmitRunOperator, the deferral helper only defers if wait_for_termination is also True; carry wait_for_termination=False over from an old submit task and it silently skips deferral and polling entirely, reproducing the exact fire-and-forget gap this section exists to remove.
This still frees the worker during polling and the task's own outcome reflects the real job result — but it isn't a strictly-better upgrade with zero tradeoff: the deferred path submits the job directly and never goes through the mixin's checkpointing step, so durable=True protects nothing here (unlike the synchronous path below). A worker crash between submission and the trigger taking over still resubmits on retry.
If no Triggerer is deployed — this is a genuine tradeoff, not an automatic call either way. It splits into two cases depending on whether holding a worker for the job's duration is acceptable:
-
Job is OK to run synchronously (short/moderate runtime, or worker capacity isn't scarce) and the operator supports durable execution (inherits
ResumableJobMixinfrom Section 5, exposesdurable=True) — recommend replacing the two-task pattern with one task in the operator's durable mode:wait_for_termination=True,durable=True(both already the default). This is not just fewer moving parts — the operator's built-in poll loop is covered byResumableJobMixinend-to-end (submit and poll), so a worker crash mid-poll reconnects instead of resubmitting. A hand-rolled sensor task is a separate, non-mixin code path with its own retry semantics — often weaker (check whether it has its ownretriesset; many don't). Collapsing here removes an entire hand-maintained file, not just a task: -
Job is genuinely long-running and worker slots are scarce enough that holding one for the full duration is the real cost to avoid — keep the
submit+mode="reschedule"sensor split. That is the legitimate, worker-efficient design in this case, not an anti-pattern to remove on sight.
Either way:
- Verify downstream tasks (and any alerting/SLA) depend on the sensor task, not
submit, if the split stays. If something downstream keys offsubmitsucceeding, that's the real bug — fix the dependency, not the architecture. - Name the tradeoff explicitly in the review (worker-slot cost and split outcome vs. single-task correctness and built-in crash-safety) rather than asserting one side is simply "the right pattern."
Crash-safety and the collapse decision are related, but they're not the same call. Whether to remove the split is decided by worker-slot cost (above) — that's independent of durable=True, which the submit task should generally keep on regardless of whether you collapse (see the "When NOT to use ResumableJobMixin" table in Section 5: the poll/sensor task needs nothing, but the submit task's own duplicate-submission risk doesn't disappear just because it's split from the poll task). When you do collapse, prefer the operator's own durable=True mode over keeping the hand-rolled sensor bolted on — the built-in path already carries ResumableJobMixin's crash-safety for both submit and poll in one place, which a hand-rolled sensor doesn't replicate.
Section 7 — Configuration reference
Worker-side backend (optional, [workers] section) — routes task state store writes through a local backend before they reach the API server. Useful when large payloads or credentialed storage should stay on the worker:
Section 8 — Safety checklist
- Airflow version ≥ 3.3 (
af config version) - Values are JSON-serializable (
str,int,float,bool,list,dict— nodatetime, no custom objects) -
task_state_storekeys are short, descriptive strings (avoid dots and slashes) - Mapped tasks writing to
asset_state_store: use distinct keys per index or accept last-writer-wins semantics - Mapped tasks: fleet-wide state clear uses CLI/core API from a downstream task, not
clear()inside the task body -
ResumableJobMixin:external_id_keyis set and will not be renamed after deployment -
ResumableJobMixin:execute()callsself.execute_resumable(context), not custom logic -
ResumableJobMixin:durable=Falseis intentional if crash recovery is disabled - Large payloads (> configured
max_value_storage_bytes) use a custom[state_store] backendor a worker side backend configured via:[workers] state_store_backend
Related skills
- authoring-dags — general DAG writing patterns and conventions.
- airflow-hitl — pausing a DAG for human approval (Airflow 3.1+).
- airflow —
af config,af registry, and general Airflow CLI reference.


