Cross-DAG dependencies are where Airflow deployments succeed or fail at scale. This expert-level lesson walks through all three coordination mechanisms—ExternalTaskSensor, Dataset-driven scheduling, and Deferrable Operators—with production-grade code, real troubleshooting, and architectural trade-offs you won't find in the docs.

Picture this: your company runs a suite of data pipelines. One DAG ingests raw transaction data from your payment processor every hour. A second DAG enriches that data with customer attributes from your CRM. A third DAG loads the enriched dataset into your analytics warehouse. A fourth DAG triggers your ML model retraining job. Each of these is a separate, independently maintained DAG—owned by different teams, running on different schedules, and deployed from different repositories.
Now the enrichment DAG starts failing silently. It's still marked as "success" in the Airflow UI because the task completed without error—it just found no data because the ingestion DAG was delayed by 23 minutes. Your warehouse DAG picks up stale data. Your ML model retrains on yesterday's features. Your business reports are wrong, and nobody knows why until a data analyst flags an anomaly three days later.
This is the cross-DAG coordination problem. It's not hypothetical—it's the failure mode that bites every data team that grows beyond a handful of pipelines. By the end of this lesson, you'll know exactly how Airflow solves this problem, when each solution applies, and how to avoid the traps that make cross-DAG dependencies brittle in production.
What you'll learn:
ExternalTaskSensor works under the hood, including how it resolves execution dates and where it commonly breaksThis lesson assumes you're already comfortable with core Airflow concepts: DAGs, operators, task instances, execution dates, and the scheduler loop. If you're newer to Airflow, review Scheduling and Orchestrating Pipelines with Airflow before continuing. You should also understand pipeline dependency concepts at the architectural level—Understanding Data Pipeline Dependencies: Upstream, Downstream, and Lineage Tracking provides solid grounding there.
Before diving into solutions, let's be precise about what makes this problem difficult. Airflow's scheduler is fundamentally time-oriented. Every DAG run is associated with a logical_date (formerly execution_date) that represents the start of the data interval being processed. The scheduler advances this date by the DAG's schedule_interval each time a run completes.
This works perfectly when all your DAGs are independent. It breaks down in three distinct ways when they need to coordinate:
Temporal drift. Two DAGs with matching schedules don't necessarily run at the same wall-clock time. One might be delayed by upstream slowness, resource contention, or a retry. If DAG B assumes DAG A's 08:00 run completed by the time DAG B's 08:15 run starts, that assumption can fail.
Schedule mismatch. DAG A might run hourly while DAG B runs daily. DAG B needs to know that all 24 of DAG A's runs for a given day have completed—not just one of them.
Ownership fragmentation. In real organizations, DAG A and DAG B are often maintained by different teams. You can't always coordinate by just adding a task to DAG A that triggers DAG B. You need a dependency mechanism that doesn't require modifying the upstream DAG.
Understanding which of these problems you're facing determines which solution you should use.
ExternalTaskSensor is the oldest and most widely used approach to cross-DAG dependencies. It works by polling the Airflow metadata database to check whether a specific task in an external DAG has reached a target state (success by default).
Here's the minimal setup:
from airflow import DAG
from airflow.sensors.external_task import ExternalTaskSensor
from airflow.operators.python import PythonOperator
from datetime import datetime, timedelta
with DAG(
dag_id="warehouse_loader",
schedule_interval="@daily",
start_date=datetime(2024, 1, 1),
catchup=False,
) as dag:
wait_for_enrichment = ExternalTaskSensor(
task_id="wait_for_enrichment_complete",
external_dag_id="customer_enrichment",
external_task_id="write_enriched_records",
# How long to wait before timing out
timeout=3600,
# How often to check
poke_interval=60,
# What state the external task must be in
allowed_states=["success"],
failed_states=["failed", "skipped"],
mode="reschedule", # Don't hold a worker slot while waiting
)
load_to_warehouse = PythonOperator(
task_id="load_to_warehouse",
python_callable=run_warehouse_load,
)
wait_for_enrichment >> load_to_warehouse
This looks straightforward, but there's a critical detail hidden in how ExternalTaskSensor matches DAG runs: it uses execution date matching by default.
When your warehouse_loader DAG runs for logical_date=2024-03-15, the ExternalTaskSensor looks for a run of customer_enrichment that has the same logical_date. If both DAGs share the same @daily schedule and the same start_date, this works perfectly.
But what if customer_enrichment runs hourly? The daily warehouse_loader run for 2024-03-15 would look for a customer_enrichment run with logical_date=2024-03-15 00:00:00—just the midnight run, not all 24. And what if the enrichment DAG's start_date is offset by an hour for historical reasons? The execution dates will never match, and your sensor will time out every single run.
This is the number one source of ExternalTaskSensor failures in production.
The fix is execution_date_fn or execution_delta:
from airflow.sensors.external_task import ExternalTaskSensor
from datetime import timedelta
# Option 1: Fixed time delta — use when the upstream DAG runs
# on the same schedule but at a different offset
wait_for_upstream = ExternalTaskSensor(
task_id="wait_for_upstream",
external_dag_id="ingestion_pipeline",
external_task_id="validate_and_commit",
# The ingestion DAG's execution date is 30 minutes earlier
execution_delta=timedelta(minutes=30),
timeout=7200,
poke_interval=120,
mode="reschedule",
)
# Option 2: execution_date_fn — use for complex schedule mappings
# e.g., your daily DAG waiting for the last hourly run of the previous day
def get_upstream_execution_date(logical_date):
"""
warehouse_loader runs daily at midnight for the previous day's data.
We want the customer_enrichment run for 23:00 of the data day.
"""
from datetime import datetime
# Return the 23:00 run of the same data day
return logical_date.replace(hour=23, minute=0, second=0, microsecond=0)
wait_for_hourly_upstream = ExternalTaskSensor(
task_id="wait_for_last_hourly_run",
external_dag_id="customer_enrichment",
external_task_id="write_enriched_records",
execution_date_fn=get_upstream_execution_date,
timeout=3600,
poke_interval=60,
mode="reschedule",
)
Warning
execution_date_fn receives a datetime and must return a datetime or a list of datetime objects. Returning a list lets you wait for multiple upstream runs—useful when your daily DAG needs all 24 hourly runs to complete. However, this queries the metadata DB once per returned datetime, so a list of 24 dates means 24 separate queries per poke. On a busy scheduler with many sensors, this adds up quickly.
The pattern for a daily DAG waiting on all hourly upstream runs looks like this:
from airflow.sensors.external_task import ExternalTaskSensor
from datetime import datetime, timedelta
from typing import List
def get_all_hourly_execution_dates(logical_date: datetime) -> List[datetime]:
"""
Given a daily execution date (midnight), return all 24 hourly
execution dates for that day that the upstream hourly DAG would have.
"""
hourly_dates = []
for hour in range(24):
hourly_dates.append(
logical_date.replace(hour=hour, minute=0, second=0, microsecond=0)
)
return hourly_dates
wait_for_all_hourly_runs = ExternalTaskSensor(
task_id="wait_for_all_hourly_enrichment_runs",
external_dag_id="customer_enrichment",
external_task_id="write_enriched_records",
execution_date_fn=get_all_hourly_execution_dates,
timeout=7200,
poke_interval=300,
mode="reschedule",
dag=dag,
)
Tip
If you don't specify external_task_id, the sensor watches the entire DAG run rather than a specific task. This is useful when you want to depend on the full DAG completing successfully, but it's slower because it checks the state of the DAG run object rather than an individual task instance.
This deserves more attention than it typically gets. The mode parameter on any Airflow sensor fundamentally changes how it consumes resources:
mode="poke" (default): The sensor holds a worker slot for its entire wait duration. It sleeps between checks but the slot is occupied. If poke_interval=60 and timeout=3600, that's one worker slot tied up for up to an hour, doing nothing 59 out of every 60 seconds.
mode="reschedule": The sensor checks the condition, and if it's not met, it releases the worker slot and schedules itself to check again after poke_interval seconds. Between checks, the slot is free for other tasks. This is almost always what you want.
The trap with reschedule mode is that your sensor's total wait time is measured from when it first started, not from the last reschedule. So if your timeout=3600 and the sensor was first scheduled at 08:00, it will give up at 09:00 regardless of how many times it's been rescheduled.
# BAD: holding a worker slot for up to 2 hours on every run
wait_with_poke = ExternalTaskSensor(
task_id="blocking_sensor",
external_dag_id="slow_upstream",
external_task_id="final_task",
mode="poke", # This is the default — and usually wrong
poke_interval=60,
timeout=7200,
)
# GOOD: releasing the slot between checks
wait_efficiently = ExternalTaskSensor(
task_id="efficient_sensor",
external_dag_id="slow_upstream",
external_task_id="final_task",
mode="reschedule", # Frees the worker slot between checks
poke_interval=120, # Check every 2 minutes
timeout=7200,
)
For a deeper look at how polling patterns affect pipeline triggers and resource usage, see Understanding Data Pipeline Triggers: Time-Based, Event-Driven, and Sensor Patterns.
Airflow 2.4 introduced a fundamentally different approach to cross-DAG dependencies: Datasets. Instead of coupling DAGs through shared execution dates (a temporal coupling), Datasets couple DAGs through shared data artifacts (a semantic coupling).
The model is simple but powerful: a producer DAG declares that it updates a Dataset. A consumer DAG declares that it needs a Dataset. Airflow automatically triggers the consumer when the producer has updated all the Datasets the consumer depends on.
Key insight
Dataset-driven scheduling inverts the dependency model. With ExternalTaskSensor, the consumer reaches back in time to check the producer's state. With Datasets, the producer emits an event forward to potential consumers. This eliminates the execution date matching problem entirely.
from airflow import DAG, Dataset
from airflow.operators.python import PythonOperator
from datetime import datetime
# Define Datasets as URI-based identifiers
# The URI is arbitrary but should be meaningful and stable
ENRICHED_TRANSACTIONS = Dataset("s3://data-warehouse/enriched/transactions/")
CUSTOMER_ATTRIBUTES = Dataset("s3://data-warehouse/dimensions/customers/")
# --- Producer DAG ---
with DAG(
dag_id="customer_enrichment",
schedule_interval="@hourly",
start_date=datetime(2024, 1, 1),
catchup=False,
) as enrichment_dag:
def enrich_transactions(**context):
# Your actual enrichment logic here
# Write output to s3://data-warehouse/enriched/transactions/
pass
enrich_task = PythonOperator(
task_id="write_enriched_records",
python_callable=enrich_transactions,
# Declare that this task updates the Dataset
outlets=[ENRICHED_TRANSACTIONS],
)
# --- Second Producer DAG ---
with DAG(
dag_id="customer_dimension_refresh",
schedule_interval="@daily",
start_date=datetime(2024, 1, 1),
catchup=False,
) as dimension_dag:
def refresh_customer_dim(**context):
# Refresh customer dimension table
pass
refresh_task = PythonOperator(
task_id="refresh_customer_dimension",
python_callable=refresh_customer_dim,
outlets=[CUSTOMER_ATTRIBUTES],
)
# --- Consumer DAG ---
# This DAG triggers automatically when BOTH Datasets have been updated
with DAG(
dag_id="warehouse_loader",
# schedule on a list of Datasets instead of a time interval
schedule=[ENRICHED_TRANSACTIONS, CUSTOMER_ATTRIBUTES],
start_date=datetime(2024, 1, 1),
catchup=False,
) as warehouse_dag:
def load_to_warehouse(**context):
# Load enriched transactions + customer attributes
pass
load_task = PythonOperator(
task_id="load_warehouse_tables",
python_callable=load_to_warehouse,
)
When schedule is a list of Datasets, the DAG runs whenever all of the listed Datasets have been updated at least once since the last run. This is an AND condition across all datasets.
There are some subtleties here that will bite you if you don't understand them.
Dataset updates are DAG-level, not run-level. If customer_enrichment runs 5 times between two warehouse_loader triggers, the warehouse_loader doesn't run 5 times—it runs once, after the first update satisfies the condition, and then resets its counter. You cannot use Dataset scheduling to ensure your consumer runs exactly once per producer run.
Datasets are identified by URI, not by actual data. Airflow doesn't inspect the actual files or tables at the URI. It tracks Dataset events in its own metadata database. The URI is just a logical identifier. Two DAGs using the same URI string will correctly share the dependency, even if the URI doesn't point to a real location.
No backfill support (as of Airflow 2.8). Dataset-triggered DAG runs cannot be backfilled in the traditional sense, because there's no concept of a "past" dataset event to replay. This is a significant limitation for teams that rely heavily on backfilling—we'll discuss workarounds later.
Note
Dataset events are stored in the Airflow metadata DB and can be queried programmatically. The table is dataset_event. You can also manually trigger Dataset updates via the Airflow REST API: POST /api/v1/datasets/events. This is useful for testing consumer DAGs without waiting for the real producer to run.
Airflow 2.9+ introduced DatasetOrTimeSchedule, allowing a DAG to trigger on either a dataset event OR a time interval, whichever comes first. This is extremely useful for SLA-sensitive pipelines:
from airflow.timetables.datasets import DatasetOrTimeSchedule
from airflow.timetables.trigger import CronTriggerTimetable
# Trigger whenever the dataset is updated, but also run at 06:00 UTC
# as a fallback even if the producer hasn't updated the dataset
with DAG(
dag_id="sla_sensitive_report",
schedule=DatasetOrTimeSchedule(
timetable=CronTriggerTimetable("0 6 * * *", timezone="UTC"),
datasets=[ENRICHED_TRANSACTIONS],
),
start_date=datetime(2024, 1, 1),
catchup=False,
) as sla_dag:
pass
This pattern is a natural fit for morning business reports that should wait for fresh data but must run by 06:00 UTC regardless.
Both ExternalTaskSensor in reschedule mode and the Dataset model solve the worker slot waste problem to different degrees. But there's a third mechanism that addresses this at the architectural level: Deferrable Operators (introduced in Airflow 2.2).
Understanding why this matters requires a brief detour into how Airflow workers and sensors interact.
In a standard Airflow deployment, the executor maintains a pool of worker slots. Each running task occupies one slot. ExternalTaskSensor in poke mode holds its slot for the entire wait duration. In reschedule mode, it releases the slot between checks, but it still occupies a slot during each check.
For high-concurrency environments—dozens of sensors checking for dozens of upstream DAGs every minute—this adds up to significant idle resource consumption. On a CeleryExecutor cluster with 20 workers, 15 of those workers might be occupied by sensors that spend 99% of their time sleeping.
Deferrable Operators solve this by moving the waiting logic entirely out of workers. A deferrable task suspends itself to a lightweight Triggerer process, freeing the worker slot completely. The Triggerer is an async process running asyncio event loops—it can manage thousands of concurrent waits with negligible CPU and memory.
The mechanism is elegant. When a deferrable operator decides it needs to wait, it calls self.defer() with a Trigger object. The Trigger contains the logic for detecting when the condition is met (implemented as an async Python coroutine). The operator's state is serialized and the worker slot is freed. The Triggerer process runs the trigger coroutine and, when the condition is met, re-queues the task to a worker slot to resume execution.
from airflow.sensors.external_task import ExternalTaskSensor
# The standard ExternalTaskSensor has a deferrable version
# available through the same class with deferrable=True
wait_for_upstream = ExternalTaskSensor(
task_id="wait_for_upstream_deferrable",
external_dag_id="ingestion_pipeline",
external_task_id="validate_and_commit",
deferrable=True, # Enable deferral
timeout=timedelta(hours=2),
poke_interval=timedelta(minutes=2),
mode="reschedule", # Still needed for correct timeout behavior
)
Warning
Deferrable operators require the Triggerer component to be running in your Airflow deployment. If you're on a managed service (Cloud Composer, MWAA, Astronomer), check whether the Triggerer is enabled. On self-hosted deployments, you need to start it explicitly: airflow triggerer. Without the Triggerer, self.defer() will raise an error and the task will fail.
For advanced use cases—say, waiting for a specific record to appear in your database, or waiting for a message on an SQS queue—you'll want to write your own deferrable sensor. This requires implementing both a Trigger class (async) and an operator class that knows how to defer to it.
import asyncio
from datetime import timedelta
from typing import Any, AsyncIterator, Tuple
from airflow.models import BaseOperator
from airflow.triggers.base import BaseTrigger, TriggerEvent
from airflow.utils.context import Context
from airflow.exceptions import AirflowException
class DatabaseRecordTrigger(BaseTrigger):
"""
Async trigger that polls a database for a specific record.
Runs in the Triggerer process — must be pure async.
"""
def __init__(
self,
table: str,
condition: str,
poll_interval: float = 60.0,
conn_id: str = "postgres_default",
):
super().__init__()
self.table = table
self.condition = condition
self.poll_interval = poll_interval
self.conn_id = conn_id
def serialize(self) -> Tuple[str, dict]:
"""
Serialize trigger state for storage in the metadata DB.
This is what gets persisted when the operator defers.
"""
return (
"my_package.triggers.DatabaseRecordTrigger",
{
"table": self.table,
"condition": self.condition,
"poll_interval": self.poll_interval,
"conn_id": self.conn_id,
},
)
async def run(self) -> AsyncIterator[TriggerEvent]:
"""
The async loop that the Triggerer runs.
Must yield a TriggerEvent when the condition is met.
"""
from airflow.providers.postgres.hooks.postgres import PostgresHook
while True:
try:
# Note: Use async DB drivers in production!
# PostgresHook is sync; for true async, use asyncpg directly
hook = PostgresHook(postgres_conn_id=self.conn_id)
records = hook.get_records(
f"SELECT COUNT(*) FROM {self.table} WHERE {self.condition}"
)
count = records[0][0]
if count > 0:
yield TriggerEvent({"status": "found", "count": count})
return
except Exception as e:
yield TriggerEvent({"status": "error", "message": str(e)})
return
await asyncio.sleep(self.poll_interval)
class WaitForDatabaseRecordSensor(BaseOperator):
"""
Deferrable sensor that waits for a specific record to appear
in a database table.
"""
def __init__(
self,
table: str,
condition: str,
poll_interval: timedelta = timedelta(minutes=1),
timeout: timedelta = timedelta(hours=2),
conn_id: str = "postgres_default",
**kwargs,
):
super().__init__(**kwargs)
self.table = table
self.condition = condition
self.poll_interval = poll_interval
self.timeout = timeout
self.conn_id = conn_id
def execute(self, context: Context) -> Any:
"""Called when the task first starts on a worker."""
self.log.info(
"Waiting for record in %s WHERE %s", self.table, self.condition
)
# Immediately defer — hand off to the Triggerer
self.defer(
trigger=DatabaseRecordTrigger(
table=self.table,
condition=self.condition,
poll_interval=self.poll_interval.total_seconds(),
conn_id=self.conn_id,
),
method_name="execute_complete",
timeout=self.timeout,
)
def execute_complete(self, context: Context, event: dict) -> Any:
"""
Called by the worker when the Triggerer fires a TriggerEvent.
The event dict is whatever we put in TriggerEvent(...).
"""
if event["status"] == "error":
raise AirflowException(
f"Trigger encountered error: {event['message']}"
)
self.log.info(
"Found %d matching records. Proceeding.", event["count"]
)
return event["count"]
The key insight here is the separation of concerns: execute() runs briefly on a worker to kick off the deferral, the DatabaseRecordTrigger.run() coroutine runs asynchronously in the Triggerer process with zero worker slot cost, and execute_complete() runs on a worker again once the condition is met.
Key insight
The Triggerer is designed to handle thousands of concurrent triggers through Python's asyncio. A single Triggerer instance can realistically manage 1000+ waiting sensors that would have consumed 1000+ worker slots under the old poke model. This is the scaling argument for adopting deferrable operators in high-volume environments.
Now that you understand all three mechanisms, let's be concrete about when to use each one.
Use it when:
Avoid it when:
execution_date_fn logic becomes a maintenance nightmare)Use it when:
Avoid it when:
Use it when:
Avoid it when:
In practice, you'll often combine approaches. Here's a real-world pattern that combines Dataset-driven triggering with a deferrable safety net:
from airflow import DAG, Dataset
from airflow.sensors.external_task import ExternalTaskSensor
from airflow.operators.python import PythonOperator
from airflow.timetables.datasets import DatasetOrTimeSchedule
from airflow.timetables.trigger import CronTriggerTimetable
from datetime import datetime, timedelta
WAREHOUSE_READY = Dataset("s3://data-warehouse/enriched/transactions/")
with DAG(
dag_id="executive_dashboard_refresh",
schedule=DatasetOrTimeSchedule(
timetable=CronTriggerTimetable("0 7 * * 1-5", timezone="America/New_York"),
datasets=[WAREHOUSE_READY],
),
start_date=datetime(2024, 1, 1),
catchup=False,
default_args={
"retries": 2,
"retry_delay": timedelta(minutes=5),
},
) as dag:
# Even though the DAG is Dataset-triggered, we add a short deferrable
# guard to handle the race condition where the dataset event fires
# before all data has actually landed
validate_data_freshness = ExternalTaskSensor(
task_id="validate_data_freshness",
external_dag_id="warehouse_loader",
external_task_id="load_warehouse_tables",
deferrable=True,
timeout=timedelta(hours=1),
poke_interval=timedelta(minutes=5),
# Look back up to 2 hours for a recent successful run
execution_date_fn=lambda dt: [
dt - timedelta(hours=h) for h in range(0, 3)
],
mode="reschedule",
)
refresh_dashboard = PythonOperator(
task_id="refresh_executive_dashboard",
python_callable=trigger_dashboard_refresh,
)
validate_data_freshness >> refresh_dashboard
This design gives you the event-driven triggering of Datasets (the dashboard refreshes promptly when new data arrives) combined with the safety of an ExternalTaskSensor guard (ensuring the warehouse load truly completed, not just that the dataset event was emitted).
Backfilling is where cross-DAG dependencies get genuinely painful. When you run airflow dags backfill --start-date 2024-01-01 --end-date 2024-03-01 warehouse_loader, you're creating hundreds of DAG runs spanning two months. Each of those runs contains an ExternalTaskSensor that will look for a corresponding upstream run with a matching execution date.
For the backfill to work correctly, the upstream DAG must also have been backfilled for the same date range, in the same Airflow environment, with matching execution dates. If the upstream was originally run in a different Airflow environment (say, you're migrating), or if its execution dates differ by even a second due to a configuration change, every sensor in your backfill will time out.
Warning
Never use ExternalTaskSensor with mode="poke" during a backfill. You'll exhaust all your worker slots immediately as hundreds of backfill task instances each hold a slot for up to timeout seconds simultaneously. Always use mode="reschedule" or deferrable=True.
For backfill scenarios, Dataset-driven scheduling has a workaround: you can manually emit Dataset events via the REST API for historical dates, and then trigger consumer DAG runs manually. It's clunky but functional:
# Emit a synthetic dataset event for backfill purposes
curl -X POST "http://airflow-webserver:8080/api/v1/datasets/events" \
-H "Content-Type: application/json" \
-u admin:admin \
-d '{
"dataset_uri": "s3://data-warehouse/enriched/transactions/",
"extra": {"backfill": true, "data_date": "2024-01-15"}
}'
A more robust pattern for backfill-safe cross-DAG coordination is to parameterize your sensors using DAG-level params:
from airflow import DAG
from airflow.sensors.external_task import ExternalTaskSensor
from airflow.models.param import Param
from datetime import datetime, timedelta
with DAG(
dag_id="warehouse_loader",
schedule_interval="@daily",
start_date=datetime(2024, 1, 1),
catchup=True,
params={
"skip_upstream_check": Param(
default=False,
type="boolean",
description="Set to True during backfill to skip ExternalTaskSensor",
),
},
) as dag:
def should_check_upstream(**context):
return not context["params"]["skip_upstream_check"]
wait_for_upstream = ExternalTaskSensor(
task_id="wait_for_upstream",
external_dag_id="customer_enrichment",
external_task_id="write_enriched_records",
mode="reschedule",
timeout=3600,
poke_interval=120,
# Skip the sensor entirely if backfilling with the flag set
# This uses Airflow's ShortCircuit pattern instead — shown below
)
A cleaner approach to the backfill problem is to use a BranchPythonOperator to conditionally skip the sensor:
from airflow.operators.python import BranchPythonOperator
from airflow.operators.empty import EmptyOperator
def choose_path(**context):
if context["params"].get("skip_upstream_check", False):
return "skip_sensor"
return "wait_for_upstream"
branch = BranchPythonOperator(
task_id="check_if_backfill",
python_callable=choose_path,
)
skip_sensor = EmptyOperator(task_id="skip_sensor")
wait_for_upstream = ExternalTaskSensor(
task_id="wait_for_upstream",
external_dag_id="customer_enrichment",
external_task_id="write_enriched_records",
mode="reschedule",
timeout=3600,
poke_interval=120,
)
join = EmptyOperator(
task_id="join",
trigger_rule="none_failed_min_one_success",
)
branch >> [skip_sensor, wait_for_upstream] >> join >> load_to_warehouse
For more context on backfilling strategies and their implications, see Understanding Data Pipeline Backfilling: Strategies for Reprocessing Historical Data.
In large organizations, cross-DAG dependencies often cross team boundaries. The analytics team's reporting DAG depends on the data engineering team's ingestion DAG. The ML team's feature store DAG depends on the analytics team's aggregation DAG. This creates organizational coupling that can be as damaging as technical coupling.
Here are patterns that work at scale:
Teams agree on Dataset URIs as contracts. The producing team owns the URI and commits to updating it when fresh data is available. The consuming team subscribes to the URI. Neither team needs to know anything about the other's DAG internals.
# Defined in a shared library package, imported by both teams
# This is your "data contract" — publish it, version it, don't change URIs
class DataContracts:
"""
Canonical Dataset URIs for cross-team dependencies.
Changing a URI is a breaking change — treat it like an API version bump.
"""
TRANSACTIONS_ENRICHED_V2 = Dataset(
"s3://data-lake/gold/transactions/enriched/v2/"
)
CUSTOMER_360_DAILY = Dataset(
"s3://data-lake/gold/dimensions/customer_360/daily/"
)
PRODUCT_CATALOG_CURRENT = Dataset(
"s3://data-lake/gold/dimensions/products/current/"
)
REVENUE_METRICS_DAILY = Dataset(
"s3://data-lake/gold/metrics/revenue/daily/"
)
This is essentially applying ETL vs ELT thinking to dependency management: standardize the interfaces between layers, and let each team own their layer's internals.
Never let your sensors run with unbounded timeouts in production. Every cross-DAG dependency should have an SLA that determines the timeout:
# Define timeouts as named constants tied to your SLA commitments
SLA_BRONZE_LAYER_COMPLETE = timedelta(hours=2) # Raw ingestion SLAs
SLA_SILVER_LAYER_COMPLETE = timedelta(hours=4) # Transformation SLAs
SLA_GOLD_LAYER_COMPLETE = timedelta(hours=6) # Aggregation SLAs
wait_for_silver = ExternalTaskSensor(
task_id="wait_for_silver_layer",
external_dag_id="customer_enrichment",
external_task_id="write_enriched_records",
timeout=SLA_SILVER_LAYER_COMPLETE.total_seconds(),
poke_interval=120,
mode="reschedule",
deferrable=True,
# When timeout is exceeded, mark as failed (not just as timed out)
# so downstream alerting fires correctly
soft_fail=False,
)
Building robust SLA tracking around these dependencies is a topic unto itself—Building and Managing Data Pipeline SLAs: Defining, Measuring, and Enforcing Freshness and Latency Guarantees in Production goes deep on this.
Sometimes your consumer DAG genuinely lives in a different Airflow deployment than your producer—different teams, different cloud accounts, different environments. ExternalTaskSensor won't work across deployments. Your options here are:
from airflow.sensors.base import BaseSensorOperator
from airflow.utils.context import Context
import requests
class CrossEnvironmentTaskSensor(BaseSensorOperator):
"""
Polls an external Airflow deployment's REST API to check
whether a specific task has completed successfully.
"""
def __init__(
self,
external_airflow_url: str,
external_dag_id: str,
external_task_id: str,
airflow_conn_id: str = "external_airflow",
**kwargs,
):
super().__init__(**kwargs)
self.external_airflow_url = external_airflow_url
self.external_dag_id = external_dag_id
self.external_task_id = external_task_id
self.airflow_conn_id = airflow_conn_id
def poke(self, context: Context) -> bool:
from airflow.hooks.base import BaseHook
conn = BaseHook.get_connection(self.airflow_conn_id)
logical_date = context["logical_date"].isoformat()
url = (
f"{self.external_airflow_url}/api/v1/dags/{self.external_dag_id}"
f"/dagRuns/{logical_date}/taskInstances/{self.external_task_id}"
)
response = requests.get(
url,
auth=(conn.login, conn.password),
timeout=10,
)
if response.status_code == 404:
self.log.info("Task instance not found yet.")
return False
response.raise_for_status()
task_state = response.json().get("state")
self.log.info("External task state: %s", task_state)
return task_state == "success"
Let's build a complete multi-pipeline workflow with all three coordination mechanisms. The scenario: an e-commerce company with three data teams.
Team A (Data Engineering) runs an hourly ingestion DAG that loads raw orders from their transactional database.
Team B (Analytics Engineering) runs a daily transformation DAG that enriches orders with product and customer dimensions, creating a Gold layer dataset.
Team C (ML Platform) runs a model retraining DAG that needs the latest Gold layer data before computing new features.
Your task:
Create a raw_orders_ingestion DAG that runs hourly and declares a Dataset outlet Dataset("s3://data-lake/bronze/orders/").
Create a orders_gold_layer DAG that:
DatasetOrTimeSchedule (Dataset-triggered but falls back to daily at 02:00 UTC)ExternalTaskSensor (deferrable) to confirm all 24 hourly ingestion runs completedDataset("s3://data-lake/gold/orders_enriched/")Create a feature_store_refresh DAG that:
Dataset("s3://data-lake/gold/orders_enriched/")CrossEnvironmentTaskSensor (stubbed) to confirm the ML platform's preprocessing job completedWrite a pytest test that:
raw_orders_ingestionorders_gold_layer DAG has at least one ExternalTaskSensor with deferrable=Truemode="poke" (enforce this as a CI check)For guidance on writing DAG tests like this, see Pipeline Testing: Unit Tests, Integration Tests, and Data Contracts.
Symptom: ExternalTaskSensor consistently times out.
Cause 1: Execution date mismatch. The most common cause. Check whether both DAGs share the same start_date, schedule_interval, and timezone. Use execution_delta or execution_date_fn to compensate.
Cause 2: The upstream task never reached success—it may be in skipped state. Add skipped to allowed_states if appropriate.
Cause 3: The upstream DAG hasn't run at all (e.g., it's paused). Check the Airflow UI.
Debug approach:
# Temporarily add this to your DAG to inspect what the sensor is looking for
from airflow.models import TaskInstance, DagRun
from sqlalchemy.orm import Session
def debug_sensor_lookup(**context):
"""Run this as a PythonOperator before your sensor to debug date matching."""
from airflow.utils.db import provide_session
@provide_session
def check(session: Session = None):
logical_date = context["logical_date"]
runs = (
session.query(DagRun)
.filter(DagRun.dag_id == "customer_enrichment")
.order_by(DagRun.execution_date.desc())
.limit(5)
.all()
)
for run in runs:
print(f" execution_date={run.execution_date}, state={run.state}")
print(f" Current logical_date={logical_date}")
check()
Symptom: Your Airflow executor is saturated; tasks queue up behind dozens of running sensors.
Cause: Sensors running in poke mode.
Fix: Switch all sensors to mode="reschedule" and/or deferrable=True. Enforce this in CI:
# test_no_poke_sensors.py
def test_no_poke_mode_sensors(dag_bag):
"""Enforce that no sensor uses poke mode, which wastes worker slots."""
from airflow.sensors.base import BaseSensorOperator
for dag_id, dag in dag_bag.dags.items():
for task in dag.tasks:
if isinstance(task, BaseSensorOperator):
assert task.mode != "poke", (
f"Task {task.task_id} in DAG {dag_id} uses mode='poke'. "
f"Use mode='reschedule' or deferrable=True instead."
)
Symptom: Producer DAG runs successfully, Dataset outlet is declared, but consumer DAG never triggers.
Cause 1: The outlets parameter is on the wrong task, or the Dataset URI doesn't match exactly (including trailing slashes).
Cause 2: The consumer DAG's schedule list doesn't match the producer's outlets URI exactly.
Cause 3: The consumer DAG is paused.
Debug approach: Check the dataset_event table directly:
SELECT
de.id,
de.dataset_id,
d.uri,
de.source_dag_id,
de.source_task_id,
de.source_run_id,
de.created_at
FROM dataset_event de
JOIN dataset d ON de.dataset_id = d.id
ORDER BY de.created_at DESC
LIMIT 20;
Also check dag_schedule_dataset_alias_reference and dataset_dag_run_queue tables to trace why a consumer might not be queuing.
Symptom: Tasks using deferrable=True immediately fail with DeferralNotAllowed or similar.
Fix: Start the Triggerer: airflow triggerer. On Kubernetes, ensure the Triggerer deployment is running. On Astronomer, it's enabled by default. On MWAA (as of 2023), the Triggerer is enabled in environments running Airflow 2.4+.
Symptom: Backfill runs all hang at the sensor task; upstream DAG also being backfilled has runs that never start because worker slots are exhausted.
Cause: Backfill is running both the consumer (sensor) and the producer concurrently. The sensors consume all available worker slots, leaving no capacity for the producer's tasks to run and satisfy the sensors.
Fix: Use max_active_tasks on your backfill command to limit concurrency, or use the --no-backfill flag on sensors during backfill runs. Better long-term: implement the branch-based backfill bypass shown earlier.
For more on error handling patterns in these scenarios, see Data Pipeline Error Handling and Recovery Strategies and Pipeline Retry Logic and Exponential Backoff: Handling Transient Failures in Data Pipelines.
Cross-DAG dependencies are one of the most complex operational challenges in production Airflow deployments—not because the primitives are complicated, but because they sit at the intersection of scheduling semantics, resource management, team coordination, and operational rigor.
Here's what you've learned:
ExternalTaskSensor is the workhorse for time-based cross-DAG coupling. Its critical footgun is execution date matching—always verify that both DAGs use compatible schedules and use execution_delta or execution_date_fn when they don't. Always use mode="reschedule" or deferrable=True in production.
Dataset-driven scheduling inverts the coupling model to semantic, event-driven dependencies. It's the right architectural choice for new pipelines when you can modify both producer and consumer. The URI-as-contract pattern enables clean cross-team coordination. Its main limitation is weak backfill support and "at least once per consumer run" semantics rather than "exactly once per producer run."
Deferrable Operators solve the resource efficiency problem at the architectural level. They require the Triggerer component but enable thousands of concurrent waits at near-zero resource cost. Custom deferrable sensors let you extend this pattern to any external system.
In production, combine these mechanisms: use Datasets for the primary triggering relationship between teams, add a deferrable ExternalTaskSensor as a correctness guard within each team's pipeline, and invest in shared Dataset URI contracts as your inter-team API.
Your immediate next steps:
poke mode and convert them to reschedule or deferrable=Truemode="poke" sensors across your DAG repositoryAs you work on designing idempotent data pipelines and building robust observability around your multi-pipeline workflows, the coordination patterns you've learned here will be the connective tissue that holds everything together.