Day 2 · Apache Airflow

DAGs, dbt,
and integrations

Advanced orchestration, dependencies, and production patterns
S2
Overview

What we're building today

Dependencies and DAG Scheduling

S4
Dependencies and scheduling

Which upstream outcome starts a task?

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()
S6
Trigger rules

How states propagate

extractFAILED transformupstream_failed send_alertSUCCESS all_successall_failed
upstream_failed means the task was never attempted because its dependency rule was not satisfied.
S7
Trigger rules

Quick reference

RuleStarts whenTypical use
all_successEvery upstream succeedsNormal processing
all_failedEvery upstream failsFailure alert
one_successAt least one succeedsBranch join
none_failedNo upstream failedJoin with skipped branches
all_doneAll upstream finishesCleanup

Data Interval

S9
Data interval

A run processes a completed period

logical_date = 2026-01-01 means “process the data for January 1.”
S10
Data interval

When does the DagRun execute?

Interval startsJan 1 · 00:00 Interval endsJan 2 · 00:00 DagRun startslogical_date: Jan 1 data accumulates period complete
S11
Catchup

The default changed in Airflow 3

VersionDefaultWhen an old DAG is enabled
Airflow 2.xcatchup=TrueHistorical intervals may all be created
Airflow 3.xcatchup=FalseOnly the latest completed interval is created
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.

Dynamic DAG Generation

S14
Dynamic DAG generation

Anti-pattern: module-level loops

for table in TABLES:  # Runs during every parse.
    dag_id = f"etl_{table}"
    dag = DAG(dag_id=dag_id, ...)
    globals()[dag_id] = dag
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.
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.
S17
DAG factories

External configuration is a compromise

Exercise: rewrite a top-level loop with .expand() and compare the DAG list with Grid view.

XCom

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)
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.
S21
XCom debugging

Inspect values before reading logs

[core]
xcom_backend = my_project.xcom_backends.S3XComBackend
S22
XCom exercise

Push and pull two independent keys

Every unique key creates a separate XCom record for that TaskInstance.
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.

Airflow + dbt

S25
Integration with dbt

dbt needs orchestration context

S26
Airflow + dbt

What orchestration adds

Capabilitydbt onlyAirflow + dbt
Start after real readinessTime-basedSensor or Asset
End-to-end visibilityTransform onlyFull pipeline
Retry granularityWhole jobSpecific task/model
Downstream actionsSeparateSame graph
S27
dbt integration · Level 1

BashOperator: simple but opaque

dbt_run = BashOperator(
    task_id="dbt_run",
    bash_command="cd /opt/dbt_project && dbt run",
)
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.
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",
    ),
)
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_ordersfct_ordersFAILED model
  • One task per model.
  • The failed node is immediately visible.
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,
)
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.
S33
Data quality gate

Never refresh BI before tests pass

dbt_transform >> refresh_bi_dashboard() >> notify_slack()
S34
dbt exercise

Evolve from cron to orchestration

  1. Start with one scheduled BashOperator.
  2. Add a deferrable SqlSensor for source readiness.
  3. Replace the shell task with a Cosmos task group.
  4. Add a deliberately failing model and compare Grid views.
  5. Send an alert containing task ID and log URL.

Connecting to external systems

S36
Connections

Centralize access to external systems

hook = PostgresHook(postgres_conn_id="my_postgres")
count = hook.get_first("SELECT COUNT(*) FROM orders")
S37
Connections exercise

Create a Postgres connection

FieldValue
Connection IDmy_postgres
TypePostgres
Hostpostgres
Database / schemaairflow
Login / passwordairflow / airflow
Port5432
Test the task with airflow tasks test, where the Airflow connection environment exists.
S38
Variables

Runtime configuration, not credentials

target_bucket = Variable.get("target_bucket")
A module-level Variable.get() creates an external lookup during every DAG parse.

Task Failures and Return Values

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()
Airflow marks a task failed only when an exception escapes the task function. Explicit validation is the task author's responsibility.
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 hides the explicit xcom_push/xcom_pull calls, but the metadata-database write still happens.
S42
XCom anti-pattern

DataFrames do not belong in metadata DB

Anti-pattern

return dataframe
  • Large serialized blob.
  • Slow UI and Scheduler.
  • Shared DB impact.

Preferred

dataframe.to_parquet(path)
return path
  • Small 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.

Best Practices

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
S45
Best practices

Production checklist

Day 2 complete

1 / 46