Lesson 5 - Guided Project: Wait, Then Load

Welcome to the Guided Project

This project puts every idea from Module 4 into one DAG, at a scale closer to CityFlow’s real pipeline than any single lesson’s demo: wait for a real file to land, then load every one of its real rows into Postgres through a hook and a named Connection — verified afterward with a real row count and real screenshots of the finished run.

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

  • Build a complete “wait for data, then load it” DAG combining a FileSensor and a PostgresHook-driven task
  • Trigger it end to end against a real, larger CityFlow data slice and confirm every row landed
  • Read a real Grid view and a real Graph view as two independent confirmations of the same finished run

The Full Pattern, One More Time, At Scale

# gate: skip
"""Module 4's guided project: CityFlow's real "wait for it to land, then
load it" DAG. A FileSensor waits for a real slice of CityFlow trip data to
land at a real path, then a PostgresHook-based task loads every real row
into the real cityflow-lessons-db Postgres instance via the same
'cityflow_postgres' Connection Lesson 2-4 used -- verified afterward with a
real SELECT COUNT(*)."""
from datetime import datetime

from airflow.decorators import task
from airflow.models.dag import DAG
from airflow.sensors.filesystem import FileSensor

LANDING_FILE = "/opt/airflow/dags/data/work/guided_project_m4/landing_trips.csv"
POSTGRES_CONN_ID = "cityflow_postgres"
TABLE_NAME = "cityflow_guided_project_trips"

with DAG(
    dag_id="cityflow_guided_project_wait_and_load",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-4", "guided-project"],
) as dag:
    wait_for_landing_file = FileSensor(
        task_id="wait_for_landing_file",
        filepath=LANDING_FILE,
        fs_conn_id="fs_default",
        poke_interval=5,
        timeout=120,
        mode="poke",
    )

    @task
    def load_landing_file_into_postgres() -> int:
        import pandas as pd
        from airflow.providers.postgres.hooks.postgres import PostgresHook

        trips = pd.read_csv(LANDING_FILE)
        hook = PostgresHook(postgres_conn_id=POSTGRES_CONN_ID)

        hook.run(f"DROP TABLE IF EXISTS {TABLE_NAME};")
        hook.run(
            f"""
            CREATE TABLE {TABLE_NAME} (
                id SERIAL PRIMARY KEY,
                pickup_time TIMESTAMP,
                passenger_count REAL,
                trip_distance REAL,
                fare_amount REAL,
                payment_type INTEGER
            );
            """
        )

        cols = ["tpep_pickup_datetime", "passenger_count", "trip_distance", "fare_amount", "payment_type"]
        rows = list(trips[cols].itertuples(index=False, name=None))
        hook.insert_rows(
            table=TABLE_NAME,
            rows=rows,
            target_fields=["pickup_time", "passenger_count", "trip_distance", "fare_amount", "payment_type"],
            commit_every=100,
        )
        print(f"loaded {len(rows)} real CityFlow trip rows into {TABLE_NAME} via PostgresHook.insert_rows()")

        row_count = hook.get_first(f"SELECT COUNT(*) FROM {TABLE_NAME};")[0]
        assert row_count == len(trips), f"expected {len(trips)} rows, got {row_count}"
        print(f"SELECT COUNT(*) via hook confirms {row_count} real rows landed in Postgres")
        return row_count

    wait_for_landing_file >> load_landing_file_into_postgres()

The shape is exactly Lesson 4’s: one FileSensor, one hook-driven load task, one >>. What’s different is scale and realism — landing_trips.csv is 500 real CityFlow trip rows instead of 15 or 30, commit_every=100 batches the inserts the way a real loader would instead of committing every single row, and the target table carries a real pickup_time column alongside the fare and passenger fields Lesson 4 loaded.


Triggered End to End: 500 Real Rows

# gate: setup: docker rm -f cityflow-lessons-db >/dev/null 2>&1; docker run -d --name cityflow-lessons-db -e POSTGRES_USER=cityflow -e POSTGRES_PASSWORD=cityflow -e POSTGRES_DB=cityflow -p 55432:5432 postgres:13 >/dev/null && sleep 4
# gate: teardown: docker rm -f cityflow-lessons-db >/dev/null 2>&1
import json
import os
import subprocess
import time

dag_id = "cityflow_guided_project_wait_and_load"
host_incoming = ".airflow-local/dags/data/work/guided_project_m4/landing_trips.csv"

os.makedirs(os.path.dirname(host_incoming), exist_ok=True)
if os.path.exists(host_incoming):
    os.remove(host_incoming)
print(f"confirmed {host_incoming} does not exist yet")

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"

subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "unpause", dag_id],
    capture_output=True, text=True, check=True,
)
run_id = f"lesson5_m4_verify_{int(time.time())}"
trigger = subprocess.run(
    ["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "trigger", dag_id, "--run-id", run_id],
    capture_output=True, text=True,
)
if trigger.returncode != 0:
    subprocess.run(["docker", "exec", "airflow-local-airflow-scheduler-1", "airflow", "dags", "reserialize"],
                    capture_output=True, text=True, check=True)
    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}")

for i in range(3):
    time.sleep(4)
    r = 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,
    )
    sensor_line = [l for l in r.stdout.splitlines() if "wait_for_landing_file" in l][0]
    print(f"poll {i}: {sensor_line.strip()}")

print(">>> creating the real landing file now (500 real trip rows) <<<")
import pandas as pd

raw = pd.read_csv(".airflow-local/dags/data/yellow_tripdata_sample.csv")
slice_ = raw.iloc[1000:1500]
slice_.to_csv(host_incoming, index=False)
print(f"wrote {len(slice_)} real rows to {host_incoming}")

deadline = time.time() + 90
state = "queued"
while state not in ("success", "failed") and time.time() < deadline:
    time.sleep(3)
    r = 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(r.stdout)
    state = next(x["state"] for x in runs if x["run_id"] == run_id)
print(f"run {run_id!r} reached state: {state}")
assert state == "success", f"expected success, got {state}"

r = 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(r.stdout)

# Independent check: query cityflow-lessons-db directly, bypassing Airflow entirely.
import psycopg2

conn = psycopg2.connect(host="localhost", port=55432, user="cityflow", password="cityflow", dbname="cityflow")
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM cityflow_guided_project_trips;")
row_count = cur.fetchone()[0]
print(f"direct psycopg2 check: {row_count} rows in cityflow_guided_project_trips")
assert row_count == 500, f"expected 500, got {row_count}"
cur.execute("SELECT pickup_time, passenger_count, trip_distance, fare_amount, payment_type FROM cityflow_guided_project_trips ORDER BY id LIMIT 3;")
for row in cur.fetchall():
    print(" ", row)
conn.close()
confirmed .airflow-local/dags/data/work/guided_project_m4/landing_trips.csv does not exist yet
triggered 'cityflow_guided_project_wait_and_load' as 'lesson5_m4_verify_1784888947'
poll 0: cityflow_guided_project_wait_and_load | 2026-07-24T10:29:09+00:00 | wait_for_landing_file | running | 2026-07-24T10:29:10.888434+00:00 |
poll 1: cityflow_guided_project_wait_and_load | 2026-07-24T10:29:09+00:00 | wait_for_landing_file | running | 2026-07-24T10:29:10.888434+00:00 |
poll 2: cityflow_guided_project_wait_and_load | 2026-07-24T10:29:09+00:00 | wait_for_landing_file | running | 2026-07-24T10:29:10.888434+00:00 |
>>> creating the real landing file now (500 real trip rows) <<<
wrote 500 real rows to .airflow-local/dags/data/work/guided_project_m4/landing_trips.csv
run 'lesson5_m4_verify_1784888947' reached state: success
dag_id                                 | execution_date            | task_id                          | state   | start_date                       | end_date
=======================================+===========================+==================================+=========+==================================+=================================
cityflow_guided_project_wait_and_load  | 2026-07-24T10:29:09+00:00 | wait_for_landing_file            | success | 2026-07-24T10:29:10.888434+00:00 | 2026-07-24T10:29:30.973907+00:00
cityflow_guided_project_wait_and_load  | 2026-07-24T10:29:09+00:00 | load_landing_file_into_postgres  | success | 2026-07-24T10:29:32.029424+00:00 | 2026-07-24T10:29:32.500992+00:00

direct psycopg2 check: 500 rows in cityflow_guided_project_trips
  (datetime.datetime(2024, 1, 1, 2, 14, 12), 1.0, 5.86, 29.6, 1)
  (datetime.datetime(2024, 1, 1, 2, 14, 17), 2.0, 3.88, 21.9, 1)
  (datetime.datetime(2024, 1, 1, 2, 14, 17), 1.0, 3.88, 21.9, 1)

All 500 real rows landed — confirmed by the task’s own hook query inside the DAG, and again by this script’s completely independent psycopg2 connection straight to cityflow-lessons-db, agreeing on the exact same number.


Two Real Screenshots of the Finished Run

Screenshot of the Airflow Grid view for cityflow_guided_project_wait_and_load, showing two task rows, wait_for_landing_file and load_landing_file_into_postgres, both green, with a Details panel showing Total success 1 out of Total Tasks 2 and Max Run Duration 00:00:22.
The real Grid view: both tasks green, Total Tasks 2, this run's total duration 22 real seconds -- almost all of it spent genuinely waiting on the sensor.
Screenshot of the Airflow Graph view for cityflow_guided_project_wait_and_load, showing two green boxes connected by an arrow: wait_for_landing_file labeled FileSensor, flowing into load_landing_file_into_postgres labeled @task, both showing a success state.
The real Graph view: `wait_for_landing_file` flowing into `load_landing_file_into_postgres`, the exact same shape as every lesson in this module, now carrying 500 real rows instead of 15 or 30.

What This Project Actually Proved

This DAG is the module’s whole argument in one file: a real sensor guarantees the load task never runs against data that isn’t there yet, and a real hook, reached through a named Connection, is how that load task talks to Postgres without a single hardcoded credential. Scaling from Lesson 1’s 15 rows to Lesson 4’s 30 to this project’s 500 changed nothing about the DAG’s structure — the same two-task shape, the same >>, the same PostgresHook(postgres_conn_id="cityflow_postgres") — because the pattern was never about the row count.


Key Takeaways

  • The same FileSensor » hook-driven-load shape scales from a 15-row demo to a 500-row real load without any structural change — only the data volume changed.
  • A real SELECT COUNT(*), checked twice through two independent code paths (the task’s own hook, and a separate psycopg2 connection), is the strongest evidence a load genuinely completed.
  • The Grid view’s duration and the Graph view’s shape are two different real facts about the same run — one shows how long, the other shows what ran and in what order — and both agree with the CLI’s task-state table.
  • Nothing in this module’s five lessons required a single hardcoded host, port, password, or table name once Lesson 3 introduced Connections and Variables — Lessons 4 and 5 both reused cityflow_postgres unchanged.

Exercises

Exercise 1 — Change LANDING_FILE’s slice to CityFlow’s full yellow_tripdata_sample.csv (all ~200,000 rows) instead of a 500-row slice, re-trigger, and confirm the real load time via the Grid view’s load_landing_file_into_postgres duration — how much longer did loading 400x the rows actually take?

Exercise 2 — Add a Variable, cityflow_guided_project_table, holding the target table name (mirroring Lesson 3’s pattern), and change load_landing_file_into_postgres to read it via Variable.get(...) instead of the hardcoded TABLE_NAME constant — confirm the DAG still loads correctly with zero hardcoded table names left anywhere in the file.

Exercise 3 — Add a second FileSensor waiting on a second landing file feeding a second load task, both running independently in the same DAG (no dependency between the two sensor-load pairs). Trigger it with only one file present and confirm one task pair reaches success while the other remains genuinely running, waiting on its own real condition.

Sponsor

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

Buy Me a Coffee at ko-fi.com