Day 2 · Apache Airflow
DAGs, dbt, and integrations
Advanced orchestration, dependencies, and production patterns
Apache Airflow · Workshop
S2
Overview
What we're building today
Schedule-aware dependencies with trigger_rule and catchup.
Scalable runtime fan-out with dynamic task mapping.
Safe task communication through XCom.
Airflow and dbt integration at several levels.
Production-safe connections, configuration, and testing.
Apache Airflow · Workshop
Dependencies and DAG Scheduling
Apache Airflow · Workshop
S4
Dependencies and scheduling
Which upstream outcome starts a task?
all_success is the default: every upstream task must succeed.
all_failed is useful for alerts that run only after failure.
one_success and one_failed react to any matching branch.
none_failed allows successful and skipped upstream tasks.
all_done is useful for unconditional cleanup and final notifications.
Apache Airflow · Workshop
S5
Trigger rules
Failure can activate another branch
@task
def extract ():
raise ValueError("Source is unavailable" )
@task
def transform ():
print("Runs only after success" )
@task (trigger_rule="all_failed" )
def send_alert ():
print("Runs because extract failed" )
extract_task = extract()
extract_task >> transform()
extract_task >> send_alert()
Apache Airflow · Workshop
S6
Trigger rules
How states propagate
extract FAILED
transform upstream_failed
send_alert SUCCESS
all_success all_failed
upstream_failed means the task was never attempted because its dependency rule was not satisfied.
Apache Airflow · Workshop
S7
Trigger rules
Quick reference
Rule Starts when Typical use
all_successEvery upstream succeeds Normal processing
all_failedEvery upstream fails Failure alert
one_successAt least one succeeds Branch join
none_failedNo upstream failed Join with skipped branches
all_doneAll upstream finishes Cleanup
Apache Airflow · Workshop
Data Interval
Apache Airflow · Workshop
S9
Data interval
A run processes a completed period
The DagRun for January 1 starts at the beginning of January 2.
Airflow waits until the whole interval is complete.
logical_date labels the data period, not the wall-clock start time.
This behavior is intentional for batch processing.
logical_date = 2026-01-01 means “process the data for January 1.”
Apache Airflow · Workshop
S10
Data interval
When does the DagRun execute?
Interval starts Jan 1 · 00:00
Interval ends Jan 2 · 00:00
DagRun starts logical_date: Jan 1
data accumulates
period complete
Apache Airflow · Workshop
S11
Catchup
The default changed in Airflow 3
Version Default When an old DAG is enabled
Airflow 2.x catchup=TrueHistorical intervals may all be created
Airflow 3.x catchup=FalseOnly the latest completed interval is created
Use explicit catchup=True when historical processing is intended.
Use backfill when you need a controlled date range.
Apache Airflow · Workshop
S12
Catchup exercise
What happens when this DAG is enabled?
@dag (
schedule="@daily" ,
start_date=datetime(2026, 1, 1),
# catchup is omitted
)
def catchup_demo ():
@task
def run ():
print("Running" )
run()
Airflow 3 creates one run for the latest completed interval. Add catchup=True to request the missed history.
Apache Airflow · Workshop
Dynamic DAG Generation
Apache Airflow · Workshop
S14
Dynamic DAG generation
Anti-pattern: module-level loops
The DAG Processor executes every DAG file in full during each scan.
A top-level loop runs again on every parse.
More entities mean more DAG objects and linearly increasing parse time.
The code works, but it adds shared scheduler overhead.
for table in TABLES: # Runs during every parse.
dag_id = f"etl_{table}"
dag = DAG(dag_id=dag_id, ...)
globals()[dag_id] = dag
Apache Airflow · Workshop
S15
Dynamic task mapping
Preferred: map work at runtime
@dag (schedule="@daily" , start_date=datetime(2026, 1, 1), catchup=False )
def etl_dynamic ():
@task
def get_tables ():
return ["orders" , "customers" , "products" ]
@task
def process (table_name: str):
print(f"Processing {table_name}" )
process.expand(table_name=get_tables())
The Scheduler parses one DAG; mapped TaskInstances are created only after get_tables runs.
Apache Airflow · Workshop
S16
Dynamic DAG generation
Parsing time is the key difference
Top-level loop
List computed during parsing. Multiple DAGs in the DAG list. Cost repeats on every scan.
.expand()
List computed during execution. One DAG in the DAG list. Mapped indexes appear in Grid.
The parameter passed to .expand(table_name=...) must match the mapped task's function argument exactly.
Apache Airflow · Workshop
S17
DAG factories
External configuration is a compromise
A factory can read YAML, JSON, or database configuration.
Configuration is separated from code and easier to review.
Reading still happens while the DAG file is parsed.
Use it when the entity list changes infrequently.
Exercise: rewrite a top-level loop with .expand() and compare the DAG list with Grid view.
Apache Airflow · Workshop
XCom
Apache Airflow · Workshop
S19
XCom
Classic explicit push and pull
def extract_classic (**context):
context["ti" ].xcom_push(key="raw_value" , value=42)
def transform_classic (**context):
value = context["ti" ].xcom_pull(
task_ids="extract_classic" ,
key="raw_value" ,
)
print(value * 2)
task_ids identifies the source task.
key identifies the value.
Apache Airflow · Workshop
S20
XCom
Multiple named outputs
@task (multiple_outputs=True )
def extract_multi ():
return {"raw_value" : 42, "source" : "api" }
result = extract_multi()
use_value(result["raw_value" ])
use_source(result["source" ])
Each dictionary key becomes an independent XCom row visible in Task Instance Details.
Apache Airflow · Workshop
S21
XCom debugging
Inspect values before reading logs
Open Task Instance Details → XCom.
Verify the source task_id, key, value, and data type.
A missing key returns None; it does not raise an exception.
For medium-sized values, use a custom backend such as S3.
[core]
xcom_backend = my_project.xcom_backends.S3XComBackend
Apache Airflow · Workshop
S22
XCom exercise
Push and pull two independent keys
Push raw_value = 42.
Push source = "api".
Pull each key separately in the downstream task.
Print: Value 42 was received from api.
Every unique key creates a separate XCom record for that TaskInstance.
Apache Airflow · Workshop
S23
XCom exercise
Diagnose the silent None
ti.xcom_push(key="result" , value=42)
value = ti.xcom_pull(
task_ids="extract_buggy" ,
key="raw_value" , # Wrong key.
)
print(value) # None
Fix the key mismatch and assert required values explicitly when None is not valid.
Apache Airflow · Workshop
Airflow + dbt
Apache Airflow · Workshop
S25
Integration with dbt
dbt needs orchestration context
dbt excels at transformation but does not orchestrate extraction and loading.
Airflow starts dbt after data is actually ready, not after a fixed delay.
One DAG provides source-to-consumer observability.
Retries can target the failed step instead of the whole pipeline.
Apache Airflow · Workshop
S26
Airflow + dbt
What orchestration adds
Capability dbt only Airflow + dbt
Start after real readiness Time-based Sensor or Asset
End-to-end visibility Transform only Full pipeline
Retry granularity Whole job Specific task/model
Downstream actions Separate Same graph
Apache Airflow · Workshop
S27
dbt integration · Level 1
BashOperator: simple but opaque
dbt_run = BashOperator(
task_id="dbt_run" ,
bash_command="cd /opt/dbt_project && dbt run" ,
)
Fastest integration to implement.
The entire dbt run appears as one task.
A failure requires reading dbt logs to identify the model.
Apache Airflow · Workshop
S28
dbt integration · Level 2
Wait for real data readiness
wait_for_load = SqlSensor(
task_id="wait_for_load" ,
conn_id="warehouse" ,
sql="""
SELECT 1 FROM load_status
WHERE load_date = '{{ ds }}' AND status = 'complete'
""" ,
deferrable=True ,
)
wait_for_load >> dbt_run
The fixed “just in case” delay disappears; dbt starts when the source is actually complete.
Apache Airflow · Workshop
S29
dbt integration · Level 3
Cosmos creates one task per model
from cosmos import DbtTaskGroup, ProjectConfig, ProfileConfig
dbt_transform = DbtTaskGroup(
group_id="dbt_transform" ,
project_config=ProjectConfig("/opt/dbt_project" ),
profile_config=ProfileConfig(
profile_name="my_profile" ,
target_name="prod" ,
),
)
Failed models are visible directly in Grid. select/exclude controls rendered models.A CI-generated manifest reduces parse time.
Apache Airflow · Workshop
S30
dbt integration
Granularity changes the debugging experience
BashOperator
dbt_run → FAILED
One task for the whole project. Logs answer which model failed.
Cosmos
stg_orders → fct_orders → FAILED model
One task per model. The failed node is immediately visible.
Apache Airflow · Workshop
S31
dbt integration · Level 4
Trigger a managed dbt Cloud job
run_dbt_cloud = DbtCloudRunJobOperator(
task_id="run_dbt_cloud" ,
dbt_cloud_conn_id="dbt_cloud_default" ,
job_id=12345,
wait_for_termination=True ,
check_interval=30,
)
dbt Cloud owns model orchestration. Airflow sees job-level status. Useful when a separate dbt team owns the project.
Apache Airflow · Workshop
S32
dbt integration · Level 5
Trigger downstream work from an Asset
fct_orders = Asset("postgres://warehouse/public/fct_orders" )
@dag (schedule=[fct_orders], catchup=False )
def refresh_bi ():
@task
def refresh_dashboard ():
print("Refresh after the table changes" )
refresh_dashboard()
The consumer runs because data changed, not because enough time probably passed.
Apache Airflow · Workshop
S33
Data quality gate
Never refresh BI before tests pass
Render dbt tests as part of the Airflow graph.
Make BI refresh depend on successful models and tests.
Stop consumers when data quality fails.
Notify with the exact failed model and Airflow log URL.
dbt_transform >> refresh_bi_dashboard() >> notify_slack()
Apache Airflow · Workshop
S34
dbt exercise
Evolve from cron to orchestration
Start with one scheduled BashOperator.
Add a deferrable SqlSensor for source readiness.
Replace the shell task with a Cosmos task group.
Add a deliberately failing model and compare Grid views.
Send an alert containing task ID and log URL.
Apache Airflow · Workshop
Connecting to external systems
Apache Airflow · Workshop
S36
Connections
Centralize access to external systems
A Connection stores host, login, password, port, and extras.
DAG code references a stable conn_id.
Credentials stay out of source code.
Create connections in UI, CLI, environment variables, or a secrets backend.
hook = PostgresHook(postgres_conn_id="my_postgres" )
count = hook.get_first("SELECT COUNT(*) FROM orders" )
Apache Airflow · Workshop
S37
Connections exercise
Create a Postgres connection
Field Value
Connection ID my_postgres
Type Postgres
Host postgres
Database / schema airflow
Login / password airflow / airflow
Port 5432
Test the task with airflow tasks test, where the Airflow connection environment exists.
Apache Airflow · Workshop
S38
Variables
Runtime configuration, not credentials
Connections answer “where and how do I connect?”
Variables hold feature flags, thresholds, and target paths.
Read Variables inside task bodies, not at module level.
Use deserialize_json=True for structured configuration.
target_bucket = Variable.get("target_bucket" )
A module-level Variable.get() creates an external lookup during every DAG parse.
Apache Airflow · Workshop
Task Failures and Return Values
Apache Airflow · Workshop
S40
Task failures
Make HTTP failures explicit
@task
def fetch_api_data ():
response = requests.get("https://api.example.com/data" )
response.raise_for_status() # 404/500 → HTTPError
return response.json()
requests.get() returns a Response even when the server answers with 404 or 500.
raise_for_status() converts that status into an HTTPError.
Airflow marks the task failed only when the exception escapes the task function.
Airflow marks a task failed only when an exception escapes the task function. Explicit validation is the task author's responsibility.
Apache Airflow · Workshop
S41
TaskFlow return values
Return values become XCom
@task
def fetch_api_data ():
...
return response.json() # Stored as key="return_value"
@task
def process_data (data):
print(data)
process_data(fetch_api_data()) # Equivalent to an xcom_pull
TaskFlow serializes the returned value and writes it to the metadata database.
The value must be serializable and is visible in Task Instance Details → XCom.
A downstream argument reads the persistent XCom record; this is not an in-memory function call.
TaskFlow hides the explicit xcom_push/xcom_pull calls, but the metadata-database write still happens.
Apache Airflow · Workshop
S42
XCom anti-pattern
DataFrames do not belong in metadata DB
Anti-pattern return dataframeLarge serialized blob. Slow UI and Scheduler. Shared DB impact.
Preferred dataframe.to_parquet(path)
return pathSmall XCom string. Data lives in object storage. Workers read the same URI.
Use S3 or GCS in production; local /tmp is not shared between workers.
Apache Airflow · Workshop
Best Practices
Apache Airflow · Workshop
S44
Testing DAGs
Catch import errors before deployment
from airflow.models import DagBag
def test_no_import_errors ():
dag_bag = DagBag()
assert len(dag_bag.import_errors) == 0
DagBag imports DAGs similarly to the DAG Processor.
Run this check in CI before merging.
Broken dependencies and syntax errors fail fast.
Apache Airflow · Workshop
S45
Best practices
Production checklist
Keep parse-time code deterministic and lightweight.
Use Connections and a secrets backend for credentials.
Pass references through XCom, not large datasets.
Prefer deferrable waiting and runtime task mapping.
Test DAG imports and failure paths in CI.
Apache Airflow · Workshop
Day 2 complete
Apache Airflow · Workshop