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 << mergeAll 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:00Look 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.226530 — 11 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

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_upstreampointing backward from the task being defined and>>/set_downstreampointing forward.- Passing a list to
>>orset_upstream/set_downstreamis 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.