Lesson 1 - The TaskFlow API

Welcome to the TaskFlow API

Module 2 wrote every DAG with @dag and @task and called it a day — the TaskFlow API is genuinely the easier way to write most DAGs, and there was no reason to slow down and show the alternative. This lesson stops and shows it: the exact same real computation, written the classic way Airflow used before TaskFlow existed, run side by side with its TaskFlow twin, with a real diff proving they produce identical output.

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

  • Write the same DAG with PythonOperator instances and explicit >>, instead of @dag/@task
  • Explain what TaskFlow’s decorators are actually doing under the hood: pushing and pulling XCom values by hand, the same mechanism, just spelled out
  • Prove two independently-written DAGs are equivalent with a real file diff, not just a read-through

The Same Report, Written Twice

Both DAGs read CityFlow’s real 265-zone lookup table, count how many zones fall in each borough, sanity-check the total, and write the result to a JSON file. TaskFlow first:

# gate: skip
"""CityFlow's real zone-by-borough report, written with the TaskFlow API.
Lesson 1 (Module 3) pairs this with cityflow_zone_report_classic_dag.py --
same three tasks, same real data, same output, two different ways of
wiring an Airflow DAG."""
from datetime import datetime

from airflow.decorators import dag, task

REPORT_PATH = "/opt/airflow/dags/data/work/zone_counts_taskflow.json"


@dag(
    dag_id="cityflow_zone_report_taskflow",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-3", "taskflow"],
)
def cityflow_zone_report_taskflow_dag():
    @task
    def extract_zone_counts() -> dict:
        """Read CityFlow's real 265-zone lookup table and count zones per borough."""
        import pandas as pd

        zones = pd.read_csv("/opt/airflow/dags/data/taxi_zone_lookup.csv")
        # One real row (LocationID 265, "Outside of NYC") has no Borough value --
        # label it explicitly instead of letting value_counts() silently drop it.
        counts = zones["Borough"].fillna("Outside of NYC").value_counts().to_dict()
        return counts

    @task
    def validate_zone_counts(counts: dict) -> dict:
        """Sanity check: the boroughs must add back up to all 265 real zones."""
        total = sum(counts.values())
        assert total == 265, f"expected 265 zones total, got {total}"
        return counts

    @task
    def report_zone_counts(counts: dict) -> None:
        import json
        import os

        os.makedirs(os.path.dirname(REPORT_PATH), exist_ok=True)
        with open(REPORT_PATH, "w") as f:
            json.dump(counts, f, sort_keys=True, indent=2)
        print(f"wrote {REPORT_PATH}")
        print(json.dumps(counts, sort_keys=True, indent=2))

    report_zone_counts(validate_zone_counts(extract_zone_counts()))


cityflow_zone_report_taskflow_dag()

Notice the real data caught something a synthetic example never would: taxi_zone_lookup.csv’s last row (LocationID 265, "Outside of NYC") has no Borough value at all. value_counts() silently drops NaN by default, which would have made the report’s boroughs sum to 264, not 265 — the assertion in validate_zone_counts exists specifically to catch a bug like that, and it did, the first time this lesson’s code ran against the real file.

Now the same report, written the classic way:

# gate: skip
"""CityFlow's real zone-by-borough report, written with classic operator
instantiation. Lesson 1 (Module 3) pairs this with
cityflow_zone_report_taskflow_dag.py -- same three tasks, same real data,
same output, wired with PythonOperator + explicit `>>` instead of the
TaskFlow API's decorators and nested calls."""
from datetime import datetime

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

REPORT_PATH = "/opt/airflow/dags/data/work/zone_counts_classic.json"


def extract_zone_counts(**context):
    """Read CityFlow's real 265-zone lookup table and count zones per borough.
    No decorator does this for us here -- the return value only becomes an
    XCom because PythonOperator pushes it under the key 'return_value' by
    default; validate_zone_counts pulls it back explicitly below."""
    import pandas as pd

    zones = pd.read_csv("/opt/airflow/dags/data/taxi_zone_lookup.csv")
    return zones["Borough"].fillna("Outside of NYC").value_counts().to_dict()


def validate_zone_counts(**context):
    ti = context["ti"]
    counts = ti.xcom_pull(task_ids="extract_zone_counts")
    total = sum(counts.values())
    assert total == 265, f"expected 265 zones total, got {total}"
    return counts


def report_zone_counts(**context):
    import json
    import os

    ti = context["ti"]
    counts = ti.xcom_pull(task_ids="validate_zone_counts")
    os.makedirs(os.path.dirname(REPORT_PATH), exist_ok=True)
    with open(REPORT_PATH, "w") as f:
        json.dump(counts, f, sort_keys=True, indent=2)
    print(f"wrote {REPORT_PATH}")
    print(json.dumps(counts, sort_keys=True, indent=2))


with DAG(
    dag_id="cityflow_zone_report_classic",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-3", "classic"],
) as dag:
    extract = PythonOperator(
        task_id="extract_zone_counts",
        python_callable=extract_zone_counts,
    )
    validate = PythonOperator(
        task_id="validate_zone_counts",
        python_callable=validate_zone_counts,
    )
    report = PythonOperator(
        task_id="report_zone_counts",
        python_callable=report_zone_counts,
    )

    extract >> validate >> report

Line up the two side by side and TaskFlow’s whole value becomes concrete: report_zone_counts(validate_zone_counts(extract_zone_counts())) and extract >> validate >> report wire the identical dependency chain, but the classic version has to say the XCom exchange out loud — ti.xcom_pull(task_ids="extract_zone_counts") in every downstream function — where TaskFlow does the same push-then-pull silently, just by nesting function calls. Nothing about what runs changed; only how much of the plumbing you have to write yourself.


Both, Deployed and Triggered for Real

Both files are saved to .airflow-local/dags/. Same pattern as Module 2: confirm the scheduler picked up both, unpause and trigger both, poll each to a terminal state.

import subprocess
import time
import json

dag_ids = ("cityflow_zone_report_taskflow", "cityflow_zone_report_classic")

for dag_id in dag_ids:
    deadline = time.time() + 320
    found = False
    while time.time() < deadline:
        result = subprocess.run(
            ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "list", "--output", "json"],
            capture_output=True, text=True, check=True,
        )
        if any(d["dag_id"] == dag_id for d in json.loads(result.stdout)):
            found = True
            break
        time.sleep(5)
    assert found, f"{dag_id} was never picked up by the scheduler"
    print(f"scheduler picked up {dag_id!r}")

run_ids = {}
for dag_id in dag_ids:
    subprocess.run(
        ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
        capture_output=True, text=True, check=True,
    )
    run_id = f"lesson1_verify_{int(time.time())}_{dag_id[-4:]}"
    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,
    )
    run_ids[dag_id] = run_id
    print(f"triggered {dag_id!r} as {run_id!r}")
    time.sleep(1)

for dag_id, run_id in run_ids.items():
    deadline = time.time() + 60
    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"{dag_id} run {run_id!r} reached state: {state}")
    assert state == "success", f"expected success, got {state}"
scheduler picked up 'cityflow_zone_report_taskflow'
scheduler picked up 'cityflow_zone_report_classic'
triggered 'cityflow_zone_report_taskflow' as 'lesson1_verify_1784885113_flow'
triggered 'cityflow_zone_report_classic' as 'lesson1_verify_1784885116_ssic'
cityflow_zone_report_taskflow run 'lesson1_verify_1784885113_flow' reached state: success
cityflow_zone_report_classic run 'lesson1_verify_1784885116_ssic' reached state: success

Both DAGs’ three tasks all reached success:

dag_id                        | task_id              | state
===============================+======================+========
cityflow_zone_report_taskflow | extract_zone_counts  | success
cityflow_zone_report_taskflow | validate_zone_counts | success
cityflow_zone_report_taskflow | report_zone_counts   | success

cityflow_zone_report_classic  | extract_zone_counts  | success
cityflow_zone_report_classic  | validate_zone_counts | success
cityflow_zone_report_classic  | report_zone_counts   | success

Proof, Not a Read-Through: Diffing the Real Output

Reading both files side by side is one kind of evidence. A real diff on what each DAG actually wrote to disk is stronger — it can’t be fooled by two implementations that merely look equivalent:

import subprocess

taskflow_json = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "cat", "/opt/airflow/dags/data/work/zone_counts_taskflow.json"],
    capture_output=True, text=True, check=True,
).stdout

classic_json = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1",
     "cat", "/opt/airflow/dags/data/work/zone_counts_classic.json"],
    capture_output=True, text=True, check=True,
).stdout

print(taskflow_json)
assert taskflow_json == classic_json, "TaskFlow and classic output diverged!"
print("IDENTICAL: TaskFlow and classic DAGs produced byte-for-byte the same output")
{
  "Bronx": 43,
  "Brooklyn": 61,
  "EWR": 1,
  "Manhattan": 69,
  "Outside of NYC": 1,
  "Queens": 69,
  "Staten Island": 20,
  "Unknown": 1
}
IDENTICAL: TaskFlow and classic DAGs produced byte-for-byte the same output

Eight boroughs (including the one real “Outside of NYC” placeholder row and the one genuinely unclassified “Unknown” zone), summing to all 265 real zones — and the two DAGs, written independently with two different mechanisms, agree on every single number.


Key Takeaways

  • The TaskFlow API’s @dag/@task and classic DAG(...) + PythonOperator + >> are two syntaxes for the same underlying engine — this lesson’s real diff proves it, not just asserts it.
  • TaskFlow’s nested function calls (report_zone_counts(validate_zone_counts(extract_zone_counts()))) both wire dependencies and pass data through XCom automatically; the classic version has to call ti.xcom_pull(task_ids="...") explicitly in every downstream task to get the same data.
  • A real bug (one zone with no Borough value) surfaced in real data the first time this DAG ran, and the validate_zone_counts assertion caught it in both versions identically — proof the two DAGs don’t just look the same, they behave the same, including how they fail.
  • When two implementations claim to be equivalent, a real file diff on their actual output is stronger evidence than reading the code and agreeing it looks right.

Exercises

Exercise 1 — Add a fourth borough-level statistic to both DAGs (e.g. the borough with the most zones) and confirm both versions still produce identical output after the change — proving the equivalence holds for a change you make yourself, not just the original code.

Exercise 2 — In the classic DAG, delete the ti.xcom_pull(task_ids="extract_zone_counts") line from validate_zone_counts and replace it with a hardcoded dict. Trigger the DAG again and use airflow tasks states-for-dag-run to confirm extract_zone_counts still runs and succeeds even though nothing downstream uses its result anymore — a task with no consumer still executes, it just becomes dead weight.

Exercise 3 — Rewrite cityflow_zone_report_classic_dag.py’s three functions to use @task (keep the PythonOperator/>> DAG structure otherwise) and confirm in the Airflow UI’s Code tab that mixing TaskFlow-decorated callables into a classic with DAG(...) block is legal — TaskFlow tasks can live inside either style of DAG definition.

Sponsor

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

Buy Me a Coffee at ko-fi.com