Lesson 3 - Dependencies

Welcome to Dependencies

Every DAG so far has used >> to mean “runs after” — simple, but it’s only one of four ways Airflow lets you say the same thing, and every DAG so far has also been a straight line: one task, then the next, then the next. This lesson does both things this course hasn’t done yet: uses all four dependency mechanisms in one DAG, and wires a DAG whose dependency graph genuinely branches — two tasks with no edge between them, both feeding a third.

By the end of this lesson, you will be able to:

  • Write the same “runs after” relationship four ways: >>, <<, .set_upstream(), .set_downstream()
  • Wire a real branch — one task feeding two independent tasks, both merging back into one — instead of a linear chain
  • Read real task start timestamps as evidence that two branch tasks actually ran in parallel, not just that the code allows it

A Real Branch, Five Edges, Four Mechanisms

cityflow_branching_dag.py ingests and validates CityFlow’s real taxi sample, computes two independent summaries — one grouped by payment_type, one grouped by passenger_count — and merges them into one report. validate_trips is the branch point: neither summary task depends on the other, only on validate_trips, which is exactly what lets Airflow run them in parallel:

# gate: skip
"""CityFlow's real branching DAG -- ingest/validate feed two independent
summary tasks in parallel, which both feed a merge step before the final
report. Deliberately wires its five edges with all four dependency
mechanisms Module 3 Lesson 3 covers: `set_downstream`, `>>` (including to a
list, the actual branch), `set_upstream`, and `<<`."""
from datetime import datetime

from airflow import DAG
from airflow.operators.python import PythonOperator

WORKDIR = "/opt/airflow/dags/data/work/m3l3"
RAW_CSV = "/opt/airflow/dags/data/yellow_tripdata_sample.csv"


def ingest_taxi_data(**context):
    import os
    import pandas as pd

    os.makedirs(WORKDIR, exist_ok=True)
    raw = pd.read_csv(RAW_CSV)
    out_path = f"{WORKDIR}/ingested.csv"
    raw.to_csv(out_path, index=False)
    print(f"ingested {len(raw):,} raw trips -> {out_path}")
    return out_path


def validate_trips(**context):
    import pandas as pd

    ti = context["ti"]
    ingested_path = ti.xcom_pull(task_ids="ingest_taxi_data")
    raw = pd.read_csv(ingested_path)
    valid = raw[
        (raw["passenger_count"] > 0)
        & (raw["trip_distance"] > 0)
        & (raw["fare_amount"] > 0)
    ]
    out_path = f"{WORKDIR}/validated.csv"
    valid.to_csv(out_path, index=False)
    print(f"kept {len(valid):,} of {len(raw):,} rows -> {out_path}")
    return out_path


def compute_by_payment_type(**context):
    import pandas as pd

    ti = context["ti"]
    validated_path = ti.xcom_pull(task_ids="validate_trips")
    valid = pd.read_csv(validated_path)
    summary = (
        valid.groupby("payment_type")
        .agg(trips=("fare_amount", "size"), avg_fare=("fare_amount", "mean"))
        .round(2)
        .reset_index()
    )
    print(summary.to_string(index=False))
    return summary.to_dict(orient="records")


def compute_by_passenger_count(**context):
    import pandas as pd

    ti = context["ti"]
    validated_path = ti.xcom_pull(task_ids="validate_trips")
    valid = pd.read_csv(validated_path)
    summary = (
        valid.groupby("passenger_count")
        .agg(trips=("fare_amount", "size"), avg_distance=("trip_distance", "mean"))
        .round(2)
        .reset_index()
    )
    print(summary.to_string(index=False))
    return summary.to_dict(orient="records")


def merge_summaries(**context):
    ti = context["ti"]
    by_payment = ti.xcom_pull(task_ids="compute_by_payment_type")
    by_passengers = ti.xcom_pull(task_ids="compute_by_passenger_count")
    return {"by_payment_type": by_payment, "by_passenger_count": by_passengers}


def load_report(**context):
    import json

    ti = context["ti"]
    merged = ti.xcom_pull(task_ids="merge_summaries")
    out_path = f"{WORKDIR}/branch_report.json"
    with open(out_path, "w") as f:
        json.dump(merged, f, indent=2)
    print(f"wrote {out_path}")
    print(json.dumps(merged, indent=2))


with DAG(
    dag_id="cityflow_branching_demo",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-3", "branching"],
) as dag:
    ingest = PythonOperator(task_id="ingest_taxi_data", python_callable=ingest_taxi_data)
    validate = PythonOperator(task_id="validate_trips", python_callable=validate_trips)
    by_payment = PythonOperator(task_id="compute_by_payment_type", python_callable=compute_by_payment_type)
    by_passengers = PythonOperator(task_id="compute_by_passenger_count", python_callable=compute_by_passenger_count)
    merge = PythonOperator(task_id="merge_summaries", python_callable=merge_summaries)
    report = PythonOperator(task_id="load_report", python_callable=load_report)

    # 1. set_downstream(): the linear start of the chain.
    ingest.set_downstream(validate)
    # 2. `>>` to a list: this is the actual branch -- both tasks depend on
    #    validate, neither depends on the other, so Airflow can run them
    #    in parallel.
    validate >> [by_payment, by_passengers]
    # 3. set_upstream() with a list: the branch closes back up here -- merge
    #    only runs once BOTH parallel tasks have succeeded.
    merge.set_upstream([by_payment, by_passengers])
    # 4. `<<`: the same relationship as `>>`, written with the arrow reversed.
    report << merge

All four mechanisms say the same kind of thing — “this task runs after that one” — just spelled differently: ingest.set_downstream(validate) and validate >> [by_payment, by_passengers] both add an edge pointing forward; merge.set_upstream([by_payment, by_passengers]) and report << merge both add an edge pointing backward from the task being defined. The one line that actually creates the branch is validate >> [by_payment, by_passengers]: passing a list to >> means “all of these depend on the task on the left,” not “these depend on each other” — by_payment and by_passengers share an upstream task and nothing else, which is the entire mechanism that lets Airflow schedule them in parallel.


Triggered for Real — and the Parallel Timestamps to Prove It

import subprocess
import time
import json

dag_id = "cityflow_branching_demo"

deadline = time.time() + 320
found = False
while time.time() < deadline:
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "details", dag_id],
        capture_output=True, text=True,
    )
    if result.returncode == 0:
        found = True
        break
    time.sleep(5)
assert found, f"{dag_id} was never picked up by the scheduler"

subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
    capture_output=True, text=True, check=True,
)
run_id = f"lesson3_verify_{int(time.time())}"
subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True, check=True,
)
print(f"triggered {dag_id!r} as {run_id!r}")

deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(3)
    result = subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1",
         "airflow", "dags", "list-runs", "-d", dag_id, "--output", "json"],
        capture_output=True, text=True, check=True,
    )
    runs = json.loads(result.stdout)
    state = next(r["state"] for r in runs if r["run_id"] == run_id)

print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"

states = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "airflow", "tasks", "states-for-dag-run", dag_id, run_id],
    capture_output=True, text=True, check=True,
)
print(states.stdout)
triggered 'cityflow_branching_demo' as 'lesson3_verify_1784885563'
run 'lesson3_verify_1784885563' reached state: success
dag_id                  | task_id                    | state   | start_date                       | end_date
========================+============================+=========+==================================+=================================
cityflow_branching_demo | ingest_taxi_data           | success | 2026-07-24T09:32:46.388879+00:00 | 2026-07-24T09:32:47.617604+00:00
cityflow_branching_demo | validate_trips             | success | 2026-07-24T09:32:48.447354+00:00 | 2026-07-24T09:32:49.472820+00:00
cityflow_branching_demo | compute_by_payment_type    | success | 2026-07-24T09:32:50.226530+00:00 | 2026-07-24T09:32:50.811255+00:00
cityflow_branching_demo | compute_by_passenger_count | success | 2026-07-24T09:32:50.215350+00:00 | 2026-07-24T09:32:50.811417+00:00
cityflow_branching_demo | merge_summaries            | success | 2026-07-24T09:32:51.290024+00:00 | 2026-07-24T09:32:51.379806+00:00
cityflow_branching_demo | load_report                | success | 2026-07-24T09:32:52.545324+00:00 | 2026-07-24T09:32:52.700145+00:00

Look at the two branch tasks’ start times: compute_by_passenger_count started at 09:32:50.215350, compute_by_payment_type at 09:32:50.22653011 milliseconds apart, both only after validate_trips ended at 09:32:49.472820, both finished before merge_summaries started at 09:32:51.290024. That’s LocalExecutor genuinely running two independent tasks in parallel, exactly as validate_trips >> [by_payment, by_passengers] allows — not a guess based on reading the code, real evidence from the real clock.

The real merged output, from branch_report.json:

{
  "by_payment_type": [
    {"payment_type": 1, "trips": 153384, "avg_fare": 18.39},
    {"payment_type": 2, "trips": 28056,  "avg_fare": 18.46},
    {"payment_type": 3, "trips": 676,    "avg_fare": 16.55},
    {"payment_type": 4, "trips": 1517,   "avg_fare": 19.27}
  ],
  "by_passenger_count": [
    {"passenger_count": 1.0, "trips": 143638, "avg_distance": 3.15},
    {"passenger_count": 2.0, "trips": 27038,  "avg_distance": 3.84},
    {"passenger_count": 3.0, "trips": 5912,   "avg_distance": 3.67},
    {"passenger_count": 4.0, "trips": 3304,   "avg_distance": 3.99},
    {"passenger_count": 5.0, "trips": 2263,   "avg_distance": 3.00},
    {"passenger_count": 6.0, "trips": 1475,   "avg_distance": 2.87},
    {"passenger_count": 8.0, "trips": 3,      "avg_distance": 0.05}
  ]
}

Of 200,000 raw trips, validate_trips kept the same 183,633 valid rows Module 2’s guided project found (the identical validity rule, over the identical sample) — split here two independent ways instead of one.


Verified a Second Way: The Real Graph View

Screenshot of the Airflow Graph view for cityflow_branching_demo, showing ingest_taxi_data flowing into validate_trips, which branches into two parallel boxes compute_by_payment_type and compute_by_passenger_count, both flowing into merge_summaries, then load_report. All six boxes are green with a success label.
The Graph view for `cityflow_branching_demo`'s real run: `ingest_taxi_data` -> `validate_trips` -> {`compute_by_payment_type`, `compute_by_passenger_count`} -> `merge_summaries` -> `load_report`. All six boxes green -- the same branch shape the CLI's timestamps already proved, now visible.

The Graph view draws exactly what the five dependency-setting lines wired: one incoming edge into validate_trips, two outgoing edges leaving it side by side, both closing back into merge_summaries. The UI and the CLI are reading the same metadata database through two different code paths, and they agree.


Key Takeaways

  • >>, <<, .set_upstream(), and .set_downstream() all do the same underlying thing — add an edge to the DAG’s dependency graph — with <</set_upstream pointing backward from the task being defined and >>/set_downstream pointing forward.
  • Passing a list to >> or set_upstream/set_downstream is what creates a real branch: every task in the list gets the same edge, but no edge is added between them, which is exactly what lets Airflow schedule them in parallel.
  • Real branch tasks (compute_by_payment_type, compute_by_passenger_count) started 11 milliseconds apart in this run — genuine parallel execution under LocalExecutor, not a coincidence of reading the DAG’s code.
  • A finished branching run is worth confirming two ways: the CLI’s real timestamps and a real Graph-view screenshot, because they’re independent evidence reading the same database differently.

Exercises

Exercise 1 — Add a third parallel branch, compute_by_pickup_hour (grouping trips by the hour extracted from tpep_pickup_datetime), depending only on validate_trips like the other two. Trigger the DAG and confirm all three branch tasks’ start timestamps land within a similar few-millisecond window.

Exercise 2 — Rewrite the DAG’s five dependency lines using only >> (no set_upstream/set_downstream/<<) and confirm the Graph view is pixel-for-pixel identical — proof the four mechanisms really are interchangeable, not four different behaviors.

Exercise 3 — Deliberately break compute_by_payment_type (e.g. reference a column that doesn’t exist) and trigger the DAG again. Use airflow tasks states-for-dag-run to confirm compute_by_passenger_count still reaches success while compute_by_payment_type reaches failed and merge_summaries reaches upstream_failed — proof that one branch failing doesn’t silently take the other branch down with it, only the tasks that actually depend on the failed one.

Sponsor

Keep DATATWEETS free. Help fund practical data, AI, and engineering lessons for learners worldwide.

Buy Me a Coffee at ko-fi.com