Lesson 2 - Hooks
Welcome to Hooks
A sensor waits for something. A hook talks to something — a database, in this lesson — through a real, tested client Airflow already ships, instead of every task author hand-rolling their own psycopg2.connect(...) call with credentials pasted inline. This lesson adds a second live service to CityFlow’s stack: cityflow-lessons-db, a real Postgres 13 container, and uses PostgresHook from inside an actual task to create a table, insert real data, and read it back.
By the end of this lesson, you will be able to:
- Stand up a second, independent Postgres instance alongside Airflow’s own metadata database, and reach it from both your host machine and from inside the Airflow containers
- Use
PostgresHook— never rawpsycopg2— to run real SQL from inside a Python task - Explain why a hook talking through a named connection is different from a task that opens its own ad-hoc database connection
A Second, Real Postgres Instance
Airflow’s own metadata database (airflow-local-postgres-1) is infrastructure, not something a course DAG should ever touch directly. This lesson’s hook needs somewhere real of its own to talk to: cityflow-lessons-db, a throwaway Postgres 13 container (the same postgres:13 image already pulled for Airflow’s metadata DB — no new image pulled), published on host port 55432.
docker run -d --name cityflow-lessons-db \
-e POSTGRES_USER=cityflow -e POSTGRES_PASSWORD=cityflow -e POSTGRES_DB=cityflow \
-p 55432:5432 postgres:13The tricky part: this container and .airflow-local’s Airflow containers are on different Docker networks — there’s no shared Compose project to put them on the same one. The fix is the port publish above plus Docker Desktop’s own host.docker.internal DNS name, which any container can use to reach the host machine:
import psycopg2
# From the host itself (this script, running in .venv-mlm-tutorials on the laptop):
conn = psycopg2.connect(host="localhost", port=55432, user="cityflow", password="cityflow", dbname="cityflow")
cur = conn.cursor()
cur.execute("SELECT version();")
print("host -> cityflow-lessons-db:", cur.fetchone()[0][:11])
conn.close()host -> cityflow-lessons-db: PostgreSQL import subprocess
# From INSIDE the Airflow scheduler container -- a different network entirely --
# host.docker.internal resolves to the host, and 55432 is the same published port.
result = subprocess.run(
["docker", "exec", "airflow-local-airflow-scheduler-1", "python", "-c",
"import psycopg2; c = psycopg2.connect(host='host.docker.internal', port=55432, "
"user='cityflow', password='cityflow', dbname='cityflow'); cur = c.cursor(); "
"cur.execute('SELECT version();'); print('scheduler ->', cur.fetchone()[0][:11]); c.close()"],
capture_output=True, text=True, check=True,
)
print(result.stdout.strip())
assert "scheduler ->" in result.stdoutscheduler -> PostgreSQL Both real connections succeed, from two different Docker networks, to the same real container — proof this cross-container path genuinely works before any lesson prose claims it does.
PostgresHook, Not Raw psycopg2
# gate: skip
"""CityFlow's real PostgresHook demo (Module 4 Lesson 2).
A single Python task uses PostgresHook -- not raw psycopg2 -- to run a real
CREATE TABLE, a real set of INSERTs (CityFlow's real borough-zone counts from
the 265-zone lookup table), and a real SELECT that reads the rows back, all
against the real cityflow-lessons-db Postgres instance via the
'cityflow_postgres' Airflow Connection."""
from datetime import datetime
from airflow.decorators import dag, task
ZONE_LOOKUP = "/opt/airflow/dags/data/taxi_zone_lookup.csv"
POSTGRES_CONN_ID = "cityflow_postgres"
TABLE_NAME = "cityflow_hook_demo"
@dag(
dag_id="cityflow_hooks_demo",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["cityflow", "module-4", "hooks"],
)
def cityflow_hooks_demo_dag():
@task
def load_borough_counts_via_hook() -> int:
"""Compute CityFlow's real borough-zone counts, then create the
table, insert every row, and read them back -- all through
PostgresHook, never a raw psycopg2 connection."""
import pandas as pd
from airflow.providers.postgres.hooks.postgres import PostgresHook
zones = pd.read_csv(ZONE_LOOKUP)
counts = zones["Borough"].fillna("Outside of NYC").value_counts().to_dict()
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,
zone_name TEXT NOT NULL,
zone_count INTEGER NOT NULL
);
"""
)
print(f"created table {TABLE_NAME} via PostgresHook.run()")
for borough, count in counts.items():
hook.run(
f"INSERT INTO {TABLE_NAME} (zone_name, zone_count) VALUES (%s, %s);",
parameters=(borough, int(count)),
)
print(f"inserted {len(counts)} real borough rows via PostgresHook.run()")
rows = hook.get_records(
f"SELECT zone_name, zone_count FROM {TABLE_NAME} ORDER BY zone_name;"
)
for zone_name, zone_count in rows:
print(f" {zone_name}: {zone_count}")
total = sum(r[1] for r in rows)
assert total == 265, f"expected 265 real zones total, got {total}"
print(f"SELECT via hook confirms {len(rows)} rows, {total} zones total")
return len(rows)
load_borough_counts_via_hook()
cityflow_hooks_demo_dag()PostgresHook(postgres_conn_id="cityflow_postgres") is the entire connection story: no host, port, user, or password appears in this file. That connection was added once, for real, via the CLI:
docker exec airflow-local-airflow-scheduler-1 airflow connections add cityflow_postgres \
--conn-type postgres --conn-host host.docker.internal --conn-port 55432 \
--conn-login cityflow --conn-password cityflow --conn-schema cityflowSuccessfully added `conn_id`=cityflow_postgres : postgres://cityflow:******@host.docker.internal:55432/cityflowLesson 3 goes deeper on Connections — the real UI’s Connections list, a real Variable, and a task that reads both instead of a hardcoded string — this lesson only needed just enough of one to make the hook work.
Triggered for Real, Verified Two Ways
# 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 subprocess
import time
dag_id = "cityflow_hooks_demo"
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"lesson2_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}")
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}"
# Independent check #2: 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 zone_name, zone_count FROM cityflow_hook_demo ORDER BY zone_name;")
rows = cur.fetchall()
for zone_name, zone_count in rows:
print(f" {zone_name}: {zone_count}")
total = sum(r[1] for r in rows)
print(f"direct psycopg2 check: {len(rows)} rows, {total} zones total")
assert total == 265, f"expected 265, got {total}"
conn.close()triggered 'cityflow_hooks_demo' as 'lesson2_m4_verify_1784888374'
run 'lesson2_m4_verify_1784888374' reached state: success
Bronx: 43
Brooklyn: 61
EWR: 1
Manhattan: 69
Outside of NYC: 1
Queens: 69
Staten Island: 20
Unknown: 1
direct psycopg2 check: 8 rows, 265 zones totalThe second check doesn’t go through Airflow, PostgresHook, or the DAG at all — it’s this script’s own psycopg2 connection, straight to cityflow-lessons-db on localhost:55432. Both reads agree: 8 real boroughs, 265 real zones, because the hook really did write them there.
The task’s own log shows the same real INSERTs, and the same masking Lesson 1 flagged:
[2026-07-24T10:19:36.888+0000] {logging_mixin.py:190} INFO - created table ***_hook_demo via PostgresHook.run()
[2026-07-24T10:19:36.891+0000] {sql.py:553} INFO - Running statement: INSERT INTO ***_hook_demo (zone_name, zone_count) VALUES (%s, %s);, parameters: ('Queens', 69)
[2026-07-24T10:19:36.892+0000] {sql.py:562} INFO - Rows affected: 1Airflow’s secret masker is scrubbing the literal word cityflow out of every log line because it matches the login/schema value stored on the cityflow_postgres Connection — genuinely helpful behavior for a real credential, slightly confusing here since cityflow is also this course’s project name, not a secret. The table name is still really cityflow_hook_demo; only the printed log text is redacted.
Key Takeaways
PostgresHook(postgres_conn_id=...)is the entire interface a task needs — no host, port, or password appears in the DAG file itself.- A hook-driven
CREATE TABLE/INSERT/SELECTwas verified two independent ways: through the hook’s ownget_records()inside the task, and through a completely separatepsycopg2connection from outside Airflow entirely — both agree on the same 265-zone total. cityflow-lessons-dbandairflow-local-postgres-1are two different Postgres containers with two different jobs — one holds Airflow’s own scheduling metadata, the other holds this lesson’s real CityFlow data. Never point a course DAG at the metadata database.- Airflow’s secret masker redacts any log text matching a configured Connection’s
login/schema/password— real security behavior, and a real reason a lesson’s own captured logs can show***where you’d expect plain text.
Exercises
Exercise 1 — Add a second task that uses PostgresHook.get_pandas_df() instead of get_records() to read cityflow_hook_demo back as a DataFrame, and confirm .sum() on its zone_count column also equals 265.
Exercise 2 — Change POSTGRES_CONN_ID to a connection ID that doesn’t exist (e.g. "nope") and trigger the DAG again. Read the real task failure in the log — confirm it’s a connection-lookup error, not a SQL error, proving the hook fails fast when the named Connection itself is missing.
Exercise 3 — Add a hook.run(f"CREATE INDEX ON {TABLE_NAME} (zone_name);") call after the table is created, and re-run the DAG. Use hook.get_records("SELECT indexname FROM pg_indexes WHERE tablename = 'cityflow_hook_demo';") in a follow-up task to confirm the real index exists.