Home DevOps Airflow Datasets: Event-Driven Triggers That Actually Work
Advanced 3 min · September 04, 2026
Airflow Datasets and Assets Scheduling

Airflow Datasets: Event-Driven Triggers That Actually Work

Airflow consumer ran before the table existed and failed daily.

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
September 04, 2026
last updated
1,750
articles · all by Naren
Before you start⏱ 25 min
  • You can write basic DAGs with schedules and tasks
  • You understand producer/consumer dependencies between DAGs
  • You run Airflow 2.4+ or 3.x with Grid view access
 ● Production Incident 🔎 Debug Guide
Quick Answer
  • Asset/dataset scheduling triggers a consumer DAG when a producer task emits an event instead of when a clock fires
  • Key components: producer outlets, named Asset/Dataset URIs, consumer schedule on assets, all-vs-any multi-asset semantics
  • Performance insight: replacing a 6 AM cron plus 40-minute retry loop with an event trigger cut wasted consumer runs by 100% and removed 4,320 daily sensor pokes
  • Production insight: Airflow 3.x renamed Datasets to Assets with back-compat, so standardize imports or triggers silently stop matching
✦ Definition~90s read
What is Airflow Datasets and Assets Scheduling?

Asset (formerly Dataset) scheduling triggers a consumer DAG when a producer task emits an event for a named data asset, replacing clock-guessing with data-ready waits. Airflow 3.x renamed Datasets to Assets with full back-compat.

Imagine ordering pizza for a party that starts at 7.
Plain-English First

Imagine ordering pizza for a party that starts at 7. A cron schedule is telling guests to eat at 7 whether the pizza arrived or not. An asset trigger is telling them to eat when the doorbell rings. One guarantees disappointment on slow-traffic days, the other guarantees hot pizza.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

The downstream DAG ran at 6 AM sharp. The upstream table landed at 6:40. That 40-minute gap cost a team a failed deploy and a very awkward status meeting.

Clocks can't see data. They fire because it's 6 AM, not because the table exists. Every fixed cron bakes in a guess about how long upstream takes, and guesses rot.

Assets flip the model. The consumer waits for the producer's event instead of the clock. You'll see how that wiring works here.

From Time-Based to Event-Driven

Time-based scheduling answers when, not whether. A cron fires at 6 AM because the clock says so, with zero knowledge of upstream state. That works while upstream is fast and breaks the first week it slows down. You'll relearn this lesson every backfill season.

Event-driven scheduling answers whether. The producer task emits an event when its output actually exists, and the consumer runs because the data is ready. Late upstream days become quiet waits instead of failed runs and manual retries.

The shift is small in code and large in operations. One schedule line changes from a cron string to an asset reference, and an entire class of timing pages disappears.

📊 Production Insight
A 6 AM cron failed 11 times in one slow month.
The asset-triggered rewrite waited quietly and ran zero retries.
Rule: couple consumers to data, not to clocks.
🎯 Key Takeaway
Clocks fire on time; events fire on readiness.
One schedule-line change removes a whole class of timing pages.
Late data should wait, not fail.

Producer and Consumer With Outlets

The producer declares its output as an outlet on the task that writes it. The scheduler records an asset event when that task succeeds. The consumer lists the same asset in schedule, so each event creates exactly one consumer run.

Names are the contract. A producer writing s3://lake/sales/dt={{ ds }} while the consumer subscribes to s3://lake/sales produces events that never match. Use one qualified naming convention and lint it.

Keep the producer on its clock and the consumer on the event. That split gives you predictable production cadence with patient downstream waits.

dags/sales_assets.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
from airflow.sdk import Asset, dag, task

sales_asset = Asset("s3://lake/sales/dt=2026-08-01")

@dag(dag_id="sales_producer", schedule="@daily", catchup=False, tags=["sales"])
def sales_producer():
    @task(outlets=[sales_asset])
    def build_partition(logical_date=None):
        ds = logical_date.strftime("%Y-%m-%d")
        # write s3://lake/sales/dt=<ds>/ then emit outlet event
        return f"built partition {ds}"
    build_partition()

sales_producer()

@dag(dag_id="sales_consumer", schedule=[sales_asset], catchup=False, tags=["sales"])
def sales_consumer():
    @task
    def aggregate():
        return "read s3://lake/sales/dt={{ ds }}/ and build rollups"
    aggregate()

sales_consumer()
📊 Production Insight
A one-character URI mismatch silenced a consumer for 6 days.
Grid lineage showed zero events; the producer looked healthy.
Rule: qualified URIs plus a naming lint in CI.
🎯 Key Takeaway
Outlets emit, schedule subscribes, names must match exactly.
Producer keeps the clock; consumer keeps the event.
Lint asset URIs like API contracts.

Multiple Datasets: All vs Any Semantics

Multiple inputs need explicit semantics. All-semantics waits for every listed asset to update before firing, which is what joins need. Any-semantics fires when any single asset updates, which suits independent feeds like per-region alerts. Airflow 3.x spells this as expressions: schedule=(a & b) waits for both, schedule=(a | b) fires on either, and you can nest them like a | (b & c) for mixed gates.

The default multi-asset behavior waits for all, but don't rely on memory. Declare the expression explicitly around join inputs so the next reader sees intent. Reserve OR for cases where a partial input is genuinely actionable. Repeated updates to one asset before the others catch up still produce a single consumer run, not one per event.

Test semantics by delaying one producer in staging. If the consumer fires early, you've got OR-semantics where you needed AND, or a misspelled asset that never participates.

📊 Production Insight
An any-wired consumer joined one fresh table to two stale ones.
Revenue rollups drifted 3% for a week before anyone noticed.
Rule: AssetAll on every join input.
🎯 Key Takeaway
Joins need AND (&); independent alerts can use OR (|).
Declare the expression explicitly so intent survives handoffs.
Repeat updates to one asset still yield one consumer run.

Airflow 3 Asset Naming and Aliases

Airflow 3.x renamed Datasets to Assets and moved imports to airflow.sdk. The old airflow.datasets Dataset class still parses as back-compat, and schedules on datasets behave like asset triggers. That's the migration runway: old DAGs keep firing while you rename.

Don't straddle the rename forever. Mixed imports across producer and consumer DAGs create events under one name that subscriptions under the other name never see. Pick airflow.sdk Asset everywhere and convert in one pass with sed plus ruff AIR30 checks.

Aliases decouple teams. Producers emit concrete URIs while consumers subscribe to an alias, so URI evolution doesn't require rewriting every downstream DAG.

dags/asset_rename_compat.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Airflow 2.4+ style (still works on 3.x via back-compat)
# from airflow.datasets import Dataset
# sales = Dataset("s3://lake/sales/dt=2026-08-01")

# Airflow 3.x style (preferred)
from airflow.sdk import Asset, AssetAlias, dag, task

sales = Asset("s3://lake/sales/dt=2026-08-01")
# Alias lets producers evolve URIs without rewriting every consumer
sales_alias = AssetAlias("sales_daily")

@dag(dag_id="sales_producer_v3", schedule="@daily", catchup=False)
def producer():
    @task(outlets=[sales])
    def build(logical_date=None):
        return "write partition, emit Asset event"
    build()

producer()
📊 Production Insight
Mixed Dataset/Asset imports silenced triggers across two teams.
One-pass rename plus ruff AIR30 restored events in an hour.
Rule: single import path per repo.
🎯 Key Takeaway
Dataset still works, Asset is the future, mixed imports silently break triggers.
Convert in one pass with AIR30 linting.
Aliases decouple producer URIs from consumer subscriptions.

Combining Schedules With Datasets

Combining clocks with events on one DAG double-fires. A consumer with both @daily and an asset schedule creates one run from the clock and one from the event, and both write the same partition. That's a restatement bug wearing a scheduling costume. When you genuinely need both, use the AssetOrTimeSchedule (AssetTimetable) integration so the DAG runs on either trigger under one timetable instead of two competing schedules.

Keep the roles clean: producers own clocks, consumers own events. When consumers need freshness SLAs, add elapsed-time alerting on event arrival instead of a competing cron. Alerting watches; crons duplicate.

Backfills complicate the picture because replaying producer history emits a burst of events. Plan consumer capacity for that burst or stagger producer history so downstream drains steadily. Queued asset events accumulate while the consumer is paused, then resolve on unpause, so a long pause followed by unpause can fire a burst you should expect.

📊 Production Insight
Dual-wired consumers double-wrote 8 partitions in a week.
Event-only wiring cut duplicate runs to zero overnight.
Rule: one DAG, one scheduling mechanism.
🎯 Key Takeaway
Clock plus event on one DAG means duplicate runs.
Producers own clocks, consumers own events.
SLAs belong in alerts, not second schedules.

Grid-View Lineage and Debugging Triggers

Grid view is your lineage debugger. Open the consumer run, follow the asset edge back to the producer event, and confirm timestamps line up. Missing edges mean missing events, not slow scheduling. The Asset Events tab on the run details page lists exactly which source events fired that run, and the Assets tab shows every registered asset with its producing tasks and subscribing DAGs.

Consumer tasks can read what fired them through triggering_asset_events: a dict of Asset to its AssetEvent list. Jinja templates pull data-interval bounds like {{ (triggering_asset_events.values() | first | first).source_dag_run.data_interval_start }}, and Python tasks accept it as a parameter to branch on run_id or timestamp. Declare assets a task reads as inlets so lineage shows consumption as well as production.

External systems push events through the REST API queued-event endpoints (/assets/queuedEvent and per-DAG variants), which suits producers outside Airflow. Pull-based AssetWatcher classes with BaseEventTrigger-compatible triggers poll queues or storage instead. When wiring looks right but runs still don't appear, check scheduler logs for asset-trigger evaluation errors. Rename mismatches after upgrades log quietly while the UI looks healthy.

🔥Silent Consumer Triage Order
When a consumer never fires, check producer outlets first, asset names second, and scheduler logs third. Ninety percent of silent consumers are missing outlet declarations, not scheduler bugs.
📊 Production Insight
Six days of silent consumer traced to an empty outlets list.
Producer was green; it simply never emitted.
Rule: outlets first, names second, logs third.
🎯 Key Takeaway
Grid lineage plus the Asset Events tab prove events flow.
triggering_asset_events hands DataIntervals to Jinja and Python.
REST push and AssetWatcher pulls cover producers outside Airflow.
● Production incidentPOST-MORTEMseverity: high

The Downstream DAG That Ran Too Early. A fixed 6 AM cron kept beating its upstream table.

Symptom
Every slow upstream day produced the same page: the 6 AM consumer failed with missing-table errors while the producer was still running. Engineers reran the consumer manually after 7 AM and it passed, which trained everyone to expect a daily failure-and-retry ritual. Backfill weeks made it worse because producer windows stretched past the consumer cron for days in a row.
Assumption
The team assumed the producer always finished by 5:30 AM because it had for months. They set the consumer cron to 6 AM with thirty minutes of imagined slack. Nobody measured p95 producer duration or considered backfill days when upstream took twice as long.
Root cause
The consumer used time-based scheduling while its input depended on an upstream DAG's completion. On normal days the table landed by 5:30 and the 6 AM run worked. On slow or backfill days the producer finished at 6:40, so the consumer queried a missing partition and failed. The schedule and the data had no coupling at all.
Fix
They removed the consumer's fixed cron and subscribed it to the producer's dataset event instead. Producer tasks declared the table URI as outlets, the consumer listed it in schedule, and Grid view lineage confirmed the wiring. Then they replayed the failed date once the table actually landed, and late producer days stopped causing failures entirely.
Key lesson
  • Clock-coupled consumers encode a guess about upstream duration that rots under load.
  • Event-driven triggers turn late upstream days from failures into patient waits.
Production debug guideTrace missing outlet events, rename mismatches, and double-fire wiring.4 entries
Symptom · 01
Consumer never triggers even though the producer succeeds
Fix
Open the producer's task instance details and confirm outlets lists the expected asset URI. If outlets is empty, the task ran but emitted nothing. Add outlets=[Asset('s3://lake/sales/dt={{ ds }}')] to the producing task and re-run one date. Declare reads as inlets=[Asset(...)] on consumers so the Assets tab shows the full edge, and use the run's Asset Events tab to confirm which event fired it.
Symptom · 02
Producer and consumer parse but the trigger silently never matches
Fix
Run ruff check --preview --select AIR30 dags/ to flag Dataset imports. Replace from airflow.datasets import Dataset with from airflow.sdk import Asset and re-parse with airflow dags list-import-errors. Keep one compat import path per repo, not both.
Symptom · 03
Consumer fires after one input lands instead of all three
Fix
Change the consumer schedule to the full asset list with all-semantics and test by delaying one producer in staging. If it still fires early, one asset name is misspelled or one producer writes a different URI than the consumer subscribes to.
Symptom · 04
Duplicate consumer runs write the same partition twice
Fix
Remove the cron from the consumer so only the asset trigger creates runs. Check airflow dags list-runs <consumer> -o table for duplicate logical dates. Keep freshness SLAs as elapsed-time alerts, not second schedules.
Cron vs Dataset vs Asset Trigger Compared
TriggerFires whenLate-data behaviorBest for
Cron scheduleClock hits the slotRuns anyway, reads stale partitionStable periodic jobs with fresh-enough inputs
Dataset outlet (2.x)Producer task emits Dataset eventConsumer waits for the eventTeam already on 2.4+ avoiding clock coupling
Asset trigger (3.x)Producer emits Asset event with alias supportWaits; conditional logic and watchers availableEvent-driven prod pipelines on Airflow 3.x
External sensorPoll finds the file or partitionWaits but holds a worker slotLegacy feeds without producer DAG control
Manual triggerHuman clicks TriggerWhatever the human believes is readyAd-hoc reruns and incident recovery
Asset expression (a & b / a | b)AND waits for all, OR fires on anyWaits per expression; repeats collapse to one runComplex gates without extra DAGs
⚙ Quick Reference
2 commands from this guide
FileCommand / CodePurpose
dagssales_assets.pyfrom airflow.sdk import Asset, dag, taskProducer and Consumer With Outlets
dagsasset_rename_compat.pyfrom airflow.sdk import Asset, AssetAlias, dag, taskAirflow 3 Asset Naming and Aliases

Key takeaways

1
Time-based consumers guess; asset triggers wait for the producer's event.
2
Producers emit via outlets, consumers subscribe via schedule on the asset.
3
Airflow 3.x calls them Assets; Dataset code keeps working as back-compat.
4
Use AND (&) expressions for joins, OR (|) only for independent feeds; repeats collapse to one run.
5
Debug silent consumers in Grid lineage plus the Asset Events tab; consumers read context via triggering_asset_events.

Common mistakes to avoid

4 patterns
×

Keeping the consumer on a fixed cron and hoping it lands after the producer

Symptom
Consumer runs before the table exists on slow days and processes yesterday's partition on late days.
Fix
Name assets as qualified URIs like s3://lake/sales/dt=2026-08-01 and keep producing tasks writing through outlets. Consumers subscribe to the asset, not to a clock time. Verify lineage in Grid view before relying on it.
×

Using any-semantics when the job needs all inputs

Symptom
Consumer fires when the first of three tables lands and computes joins against two empty partitions.
Fix
Declare all upstream assets with AssetAll semantics or a single combined asset. Test by delaying one producer in staging and confirming the consumer waits. Use AssetAny only for genuinely independent feeds.
×

Mixing airflow.datasets imports with airflow.sdk imports across DAGs

Symptom
Producer registers a Dataset the consumer's Asset never matches, so the trigger silently never fires.
Fix
Import from airflow.sdk (Asset, AssetAlias) on 3.x and keep a compat shim for 2.x. Run ruff check --select AIR30 over DAGs before upgrading so renames don't break parsing at schedule time.
×

Stacking a tight cron and an asset trigger on the same consumer

Symptom
Duplicate runs pile up: one from the clock, one from the event, writing the same partition twice.
Fix
Keep schedules on the producer and asset triggers on the consumer, not both clocks plus assets on the same DAG. If you need freshness SLAs, add an elapsed-time sensor on the consumer instead of a competing cron.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01JUNIOR
Why did the downstream DAG run before the table existed?
Q02SENIOR
How do producer and consumer DAGs connect through assets?
Q03SENIOR
What breaks at scale with asset-driven scheduling?
Q01 of 03JUNIOR

Why did the downstream DAG run before the table existed?

ANSWER
The consumer ran on a fixed cron ahead of the producer's completion window. On slow days the table didn't exist yet, so the job failed or read stale data. Moving the consumer to a dataset/asset trigger makes it wait for the producer's outlet event instead of guessing a time.
FAQ · 5 QUESTIONS

Frequently Asked Questions

01
Is Dataset code still working on Airflow 3.x?
02
How do I require all three upstream tables before running?
03
Can a DAG have both a cron schedule and asset triggers?
04
My consumer never triggers. Where do I look first?
05
How granular should assets be?
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
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 Backfill and Catchup in Depth
20 / 37 · Airflow
Next
Airflow Executors Explained