Lesson 4 - Combining Sensors and Hooks

Welcome to Combining Sensors and Hooks

Lesson 1 waited. Lesson 2 and 3 talked to Postgres. Real pipelines need both in the same DAG: wait for the data to actually exist, then load it — never load first and hope, never assume a fixed delay covers the wait. This lesson wires exactly that into one DAG.

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

  • Chain a FileSensor directly into a hook-driven load task in one DAG
  • Predict, and then confirm, that the load task only starts once the sensor’s real condition is met — never before
  • Read a Graph view where a FileSensor box and an @task box are directly connected, and know what real event each one represents

Wait, Then Load: One DAG

# gate: skip
"""CityFlow's real sensor-then-hook demo (Module 4 Lesson 4).

One DAG, two ideas composed: a FileSensor genuinely waits for a real batch of
CityFlow trip data to land, then a PostgresHook-based task loads that file's
real rows into the real cityflow-lessons-db Postgres instance -- wired end to
end, not two separate demos glued together in prose."""
from datetime import datetime

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

INCOMING_BATCH = "/opt/airflow/dags/data/work/sensor_hook_demo/incoming_batch.csv"
POSTGRES_CONN_ID = "cityflow_postgres"
TABLE_NAME = "cityflow_trip_batches"

with DAG(
    dag_id="cityflow_sensor_and_hook_demo",
    schedule=None,
    start_date=datetime(2026, 1, 1),
    catchup=False,
    tags=["cityflow", "module-4", "sensors-and-hooks"],
) as dag:
    wait_for_batch = FileSensor(
        task_id="wait_for_incoming_batch",
        filepath=INCOMING_BATCH,
        fs_conn_id="fs_default",
        poke_interval=5,
        timeout=120,
        mode="poke",
    )

    @task
    def load_batch_into_postgres() -> int:
        """Read the real trip rows the sensor just confirmed landed, and
        load them into Postgres through PostgresHook, not raw psycopg2."""
        import pandas as pd
        from airflow.providers.postgres.hooks.postgres import PostgresHook

        batch = pd.read_csv(INCOMING_BATCH)
        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,
                passenger_count REAL,
                trip_distance REAL,
                fare_amount REAL,
                payment_type INTEGER
            );
            """
        )

        rows = list(
            batch[["passenger_count", "trip_distance", "fare_amount", "payment_type"]]
            .itertuples(index=False, name=None)
        )
        hook.insert_rows(
            table=TABLE_NAME,
            rows=rows,
            target_fields=["passenger_count", "trip_distance", "fare_amount", "payment_type"],
        )
        print(f"loaded {len(rows)} real rows into {TABLE_NAME} via PostgresHook.insert_rows()")

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

    wait_for_batch >> load_batch_into_postgres()

wait_for_batch >> load_batch_into_postgres() is the whole idea: one arrow, connecting Lesson 1’s pattern directly to Lesson 2/3’s. load_batch_into_postgres cannot start until wait_for_incoming_batch reports success — Airflow’s own dependency mechanics guarantee the ordering, the same >> Module 3 used for a plain data dependency now gating a hook-driven load behind a real sensor.


Triggered End to End: File Absent, Then Landed, Then Loaded

# 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_sensor_and_hook_demo"
host_incoming = ".airflow-local/dags/data/work/sensor_hook_demo/incoming_batch.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"lesson4_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_incoming_batch" in l][0]
    print(f"poll {i}: {sensor_line.strip()}")

print(">>> creating the real incoming batch file now (30 real trip rows) <<<")
import pandas as pd

raw = pd.read_csv(".airflow-local/dags/data/yellow_tripdata_sample.csv")
slice_ = raw.iloc[200:230]
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.
import psycopg2

conn = psycopg2.connect(host="localhost", port=55432, user="cityflow", password="cityflow", dbname="cityflow")
cur = conn.cursor()
cur.execute("SELECT COUNT(*) FROM cityflow_trip_batches;")
row_count = cur.fetchone()[0]
print(f"direct psycopg2 check: {row_count} rows in cityflow_trip_batches")
assert row_count == 30, f"expected 30, got {row_count}"
conn.close()
confirmed .airflow-local/dags/data/work/sensor_hook_demo/incoming_batch.csv does not exist yet
triggered 'cityflow_sensor_and_hook_demo' as 'lesson4_m4_verify_1784888739'
poll 0: cityflow_sensor_and_hook_demo | 2026-07-24T10:25:41+00:00 | wait_for_incoming_batch | running | 2026-07-24T10:25:42.856564+00:00 |
poll 1: cityflow_sensor_and_hook_demo | 2026-07-24T10:25:41+00:00 | wait_for_incoming_batch | running | 2026-07-24T10:25:42.856564+00:00 |
poll 2: cityflow_sensor_and_hook_demo | 2026-07-24T10:25:41+00:00 | wait_for_incoming_batch | running | 2026-07-24T10:25:42.856564+00:00 |
>>> creating the real incoming batch file now (30 real trip rows) <<<
wrote 30 real rows to .airflow-local/dags/data/work/sensor_hook_demo/incoming_batch.csv
run 'lesson4_m4_verify_1784888739' reached state: success
dag_id                        | execution_date            | task_id                  | state   | start_date                       | end_date
==============================+===========================+==========================+=========+==================================+=================================
cityflow_sensor_and_hook_demo | 2026-07-24T10:25:41+00:00 | wait_for_incoming_batch  | success | 2026-07-24T10:25:42.856564+00:00 | 2026-07-24T10:26:02.965706+00:00
cityflow_sensor_and_hook_demo | 2026-07-24T10:25:41+00:00 | load_batch_into_postgres | success | 2026-07-24T10:26:03.537991+00:00 | 2026-07-24T10:26:04.007408+00:00

direct psycopg2 check: 30 rows in cityflow_trip_batches

Three real polls, all running, before the file existed — then wait_for_incoming_batch finished at 10:26:02.965706 and load_batch_into_postgres started barely half a second later at 10:26:03.537991. The load task genuinely could not have started earlier; nothing about its own code enforces that ordering; Airflow’s dependency graph does.


The Graph View: Sensor Feeding Hook, Directly

Screenshot of the Airflow Graph view for cityflow_sensor_and_hook_demo, showing two green boxes connected by an arrow: wait_for_incoming_batch labeled FileSensor, flowing into load_batch_into_postgres labeled @task, both showing a success state.
The real Graph view: `wait_for_incoming_batch` (a `FileSensor`) flowing directly into `load_batch_into_postgres` (an `@task`) -- the same shape as the DAG file's single `>>`, both boxes green.

Key Takeaways

  • A sensor and a hook-driven task compose with the same >> operator Module 3 used for any dependency — no special syntax for “wait, then load.”
  • The load task’s real start timestamp, barely half a second after the sensor’s real finish timestamp, is direct evidence the dependency is enforced, not just declared.
  • 30 real CityFlow trip rows, verified twice — once through the task’s own hook query, once through a completely separate psycopg2 connection — landed in Postgres only after the file genuinely existed.
  • This pattern (FileSensor » hook-driven load) is the exact shape Lesson 5’s guided project scales up to real CityFlow data.

Exercises

Exercise 1 — Add a third task, validate_loaded_rows, downstream of load_batch_into_postgres, that uses PostgresHook.get_first() to assert the loaded row count matches the source file’s row count — a real data-quality check reading its own answer back from Postgres instead of trusting the loader’s return value.

Exercise 2 — Trigger the DAG twice in a row without removing the incoming file between runs. Confirm the second run’s sensor succeeds immediately (no real waiting) since the file was already there, while the load task’s DROP TABLE IF EXISTS still makes the whole DAG safely re-runnable.

Exercise 3 — Using this run’s real timestamps, calculate how many real seconds the sensor spent waiting versus how many the load task took to run. What does that ratio suggest about where a real pipeline’s total latency actually comes from when the wait is the long part?

Sponsor

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

Buy Me a Coffee at ko-fi.com