Lesson 3 - Validate and Transform Tasks
Welcome to Validate and Transform Tasks
Lesson 1 argued that validate and transform belong in separate tasks so a bad batch stops before wasted transform work runs. This lesson makes both tasks real. validate_trips doesn’t just check len(df) > 0 — it runs five genuine data-quality checks against every real ingested row and writes what it found somewhere a human (or another task) can actually inspect. transform_daily_borough_summary only ever sees the rows that passed, and does real, meaningful aggregation work on them.
By the end of this lesson, you will be able to:
- Write real null, range, and ordering checks against a real DataFrame, and combine them into one overall pass/fail decision
- Explain why validation results belong in a file, not just a
print()statement - Join and aggregate real data into a genuinely different, smaller, more useful shape
Five Real Checks, Not One len() > 0
validate_trips reads Lesson 2’s real raw_trips.csv and checks it five different ways, each one a real condition a real downstream consumer would care about:
| Check | What it catches |
|---|---|
null_required_fields | Missing passenger_count, trip_distance, fare_amount, PULocationID, or DOLocationID |
trip_distance_out_of_range | Negative or implausibly long (> 100 miles) trips |
fare_amount_out_of_range | Negative or implausibly high (> 500) fares |
passenger_count_out_of_range | Fewer than 0 or more than 6 passengers |
pickup_after_dropoff | A pickup timestamp later than its own dropoff timestamp |
A row fails validation if it fails any of these — the checks aren’t mutually exclusive, so the task reports each one’s own count as well as the combined total.
# gate: skip
"""CityFlow's real validate + transform tasks (Module 5 Lesson 3).
Two real, separate tasks -- not one script doing both jobs. validate_trips
runs genuine data-quality checks (schema/null checks, range checks on
distance/fare/passenger_count, a pickup-before-dropoff time check) against a
real day of CityFlow trips and writes its findings to an inspectable JSON
report plus a quarantine CSV of the rows that failed, instead of just
printing a count and discarding it. transform_daily_borough_summary only
ever sees the rows validate_trips passed, and does real aggregation work:
joining pickup zone to borough and computing a real per-day/per-borough
summary.
ingest_taxi_trips is unchanged from Lesson 2 -- reused here, not
reimplemented, so this DAG has real input to validate against."""
from datetime import datetime
from airflow.decorators import dag, task
PARQUET_PATH = "/opt/airflow/dags/data/yellow_tripdata_2024-01.parquet"
ZONE_LOOKUP_PATH = "/opt/airflow/dags/data/taxi_zone_lookup.csv"
WORK_DIR = "/opt/airflow/dags/data/work/etl_validate_transform_demo"
RAW_TRIPS_PATH = f"{WORK_DIR}/raw_trips.csv"
CLEAN_TRIPS_PATH = f"{WORK_DIR}/clean_trips.csv"
QUARANTINE_PATH = f"{WORK_DIR}/quarantine_trips.csv"
VALIDATION_REPORT_PATH = f"{WORK_DIR}/validation_report.json"
TRANSFORMED_PATH = f"{WORK_DIR}/daily_borough_summary.csv"
INGEST_START = "2024-01-01"
INGEST_END_EXCLUSIVE = "2024-01-02"
KEEP_COLUMNS = [
"tpep_pickup_datetime", "tpep_dropoff_datetime", "passenger_count",
"trip_distance", "PULocationID", "DOLocationID", "payment_type",
"fare_amount", "tip_amount", "total_amount",
]
MIN_PASS_RATE = 0.5 # sanity floor -- real data comfortably clears this
@dag(
dag_id="cityflow_etl_validate_transform_demo",
schedule=None,
start_date=datetime(2026, 1, 1),
catchup=False,
tags=["cityflow", "module-5", "validate-transform"],
)
def cityflow_etl_validate_transform_demo_dag():
@task
def ingest_taxi_trips() -> int:
import os
import pandas as pd
raw = pd.read_parquet(PARQUET_PATH, columns=["tpep_pickup_datetime"] + [
c for c in KEEP_COLUMNS if c != "tpep_pickup_datetime"
])
raw["tpep_pickup_datetime"] = pd.to_datetime(raw["tpep_pickup_datetime"])
raw["tpep_dropoff_datetime"] = pd.to_datetime(raw["tpep_dropoff_datetime"])
start = pd.Timestamp(INGEST_START)
end = pd.Timestamp(INGEST_END_EXCLUSIVE)
sliced = raw[(raw["tpep_pickup_datetime"] >= start) & (raw["tpep_pickup_datetime"] < end)]
sliced = sliced[KEEP_COLUMNS]
os.makedirs(WORK_DIR, exist_ok=True)
sliced.to_csv(RAW_TRIPS_PATH, index=False)
print(f"ingested {len(sliced)} real CityFlow trips -> {RAW_TRIPS_PATH}")
return len(sliced)
@task
def validate_trips(ingested_rows: int) -> dict:
"""Real data-quality checks against every real ingested row.
Writes a real JSON report and a real quarantine CSV -- nothing here
is just printed and thrown away."""
import json
import pandas as pd
trips = pd.read_csv(RAW_TRIPS_PATH, parse_dates=["tpep_pickup_datetime", "tpep_dropoff_datetime"])
assert len(trips) == ingested_rows, "raw_trips.csv row count doesn't match what ingest reported"
required = ["passenger_count", "trip_distance", "fare_amount", "PULocationID", "DOLocationID"]
null_mask = trips[required].isna().any(axis=1)
dist_mask = (trips["trip_distance"] < 0) | (trips["trip_distance"] > 100)
fare_mask = (trips["fare_amount"] < 0) | (trips["fare_amount"] > 500)
pax_mask = (trips["passenger_count"] < 0) | (trips["passenger_count"] > 6)
time_mask = trips["tpep_pickup_datetime"] > trips["tpep_dropoff_datetime"]
checks = {
"null_required_fields": int(null_mask.sum()),
"trip_distance_out_of_range": int(dist_mask.sum()),
"fare_amount_out_of_range": int(fare_mask.sum()),
"passenger_count_out_of_range": int(pax_mask.sum()),
"pickup_after_dropoff": int(time_mask.sum()),
}
any_fail = null_mask | dist_mask | fare_mask | pax_mask | time_mask
rows_failed = int(any_fail.sum())
rows_passed = int((~any_fail).sum())
pass_rate = rows_passed / len(trips)
report = {
"total_rows": len(trips), "checks": checks,
"rows_failed_any_check": rows_failed, "rows_passed": rows_passed,
"pass_rate": round(pass_rate, 4),
}
with open(VALIDATION_REPORT_PATH, "w") as f:
json.dump(report, f, indent=2)
print(f"validation report -> {VALIDATION_REPORT_PATH}: {report}")
trips[any_fail].to_csv(QUARANTINE_PATH, index=False)
trips[~any_fail].to_csv(CLEAN_TRIPS_PATH, index=False)
print(f"{rows_failed} rows quarantined -> {QUARANTINE_PATH}")
print(f"{rows_passed} clean rows -> {CLEAN_TRIPS_PATH}")
assert pass_rate >= MIN_PASS_RATE, (
f"pass_rate {pass_rate:.4f} is below the {MIN_PASS_RATE} floor -- "
f"stopping here on purpose so transform never runs on data this bad"
)
return report
@task
def transform_daily_borough_summary(validation_report: dict) -> dict:
"""Real, meaningful work on the validated rows only: join each
trip's pickup zone to its borough, derive trip duration, and
aggregate to a real per-day/per-borough summary -- not a
pass-through copy."""
import pandas as pd
clean = pd.read_csv(CLEAN_TRIPS_PATH, parse_dates=["tpep_pickup_datetime", "tpep_dropoff_datetime"])
assert len(clean) == validation_report["rows_passed"], "clean_trips.csv doesn't match validate_trips's count"
zones = pd.read_csv(ZONE_LOOKUP_PATH)[["LocationID", "Borough"]].rename(columns={"LocationID": "PULocationID"})
clean = clean.merge(zones, on="PULocationID", how="left")
clean["Borough"] = clean["Borough"].fillna("Unknown")
clean["trip_duration_minutes"] = (
clean["tpep_dropoff_datetime"] - clean["tpep_pickup_datetime"]
).dt.total_seconds() / 60.0
clean["pickup_date"] = clean["tpep_pickup_datetime"].dt.date
agg = clean.groupby(["pickup_date", "Borough"]).agg(
trip_count=("fare_amount", "size"),
avg_fare_amount=("fare_amount", "mean"),
avg_trip_distance=("trip_distance", "mean"),
avg_trip_duration_minutes=("trip_duration_minutes", "mean"),
total_revenue=("total_amount", "sum"),
).reset_index()
for col in ("avg_fare_amount", "avg_trip_distance", "avg_trip_duration_minutes", "total_revenue"):
agg[col] = agg[col].round(2)
agg.to_csv(TRANSFORMED_PATH, index=False)
total_trip_count = int(agg["trip_count"].sum())
assert total_trip_count == validation_report["rows_passed"], (
f"transformed total_trip_count {total_trip_count} != "
f"validate_trips's rows_passed {validation_report['rows_passed']}"
)
print(f"{len(agg)} real day/borough summary rows -> {TRANSFORMED_PATH}, total_trip_count={total_trip_count}")
return {"summary_rows": len(agg), "total_trip_count": total_trip_count}
transform_daily_borough_summary(validate_trips(ingest_taxi_trips()))
cityflow_etl_validate_transform_demo_dag()Two details worth calling out. First, validate_trips writes three real artifacts, not one: the JSON report (the numbers), a quarantine CSV (the actual bad rows, for someone to go look at), and a clean CSV (what transform is allowed to see). Second, transform_daily_borough_summary doesn’t just trust that clean_trips.csv has the right number of rows — it asserts len(clean) == validation_report["rows_passed"], a real invariant check that the file transform is reading actually is the file validate said it wrote, passed forward through XCom rather than assumed.
Triggered for Real: 11,723 Quarantined, 69,290 Clean
# gate: skip
import json
import subprocess
import time
dag_id = "cityflow_etl_validate_transform_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"lesson3_m5_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: read the real report and real files the tasks wrote.
with open(".airflow-local/dags/data/work/etl_validate_transform_demo/validation_report.json") as f:
report = json.load(f)
print("validation_report.json:", json.dumps(report, indent=2))
import pandas as pd
q = pd.read_csv(".airflow-local/dags/data/work/etl_validate_transform_demo/quarantine_trips.csv")
c = pd.read_csv(".airflow-local/dags/data/work/etl_validate_transform_demo/clean_trips.csv")
agg = pd.read_csv(".airflow-local/dags/data/work/etl_validate_transform_demo/daily_borough_summary.csv")
print(f"direct file check: {len(q)} quarantined, {len(c)} clean, {len(agg)} summary rows")
assert len(q) == report["rows_failed_any_check"]
assert len(c) == report["rows_passed"]
assert agg["trip_count"].sum() == report["rows_passed"]triggered 'cityflow_etl_validate_transform_demo' as 'lesson3_m5_verify_1784914302'
run 'lesson3_m5_verify_1784914302' reached state: success
validation_report.json: {
"total_rows": 81013,
"checks": {
"null_required_fields": 10518,
"trip_distance_out_of_range": 1,
"fare_amount_out_of_range": 1307,
"passenger_count_out_of_range": 4,
"pickup_after_dropoff": 2
},
"rows_failed_any_check": 11723,
"rows_passed": 69290,
"pass_rate": 0.8553
}
direct file check: 11723 quarantined, 69290 clean, 7 summary rowsThe real breakdown is genuinely informative: null_required_fields (10,518 rows, almost entirely missing passenger_count) dwarfs every other check, and fare_amount_out_of_range (1,307 rows — real negative fares from refunds, and a handful of implausibly high ones) is the second-biggest real issue. trip_distance_out_of_range, passenger_count_out_of_range, and pickup_after_dropoff each catch only a handful of rows — real, but rare. An 85.53% pass rate on real, unfiltered NYC taxi data is a believable number precisely because it’s real: production data is never 100% clean, and a validation report that always shows zero failures is a report nobody would trust.
The real aggregation transform produced from the 69,290 clean rows:
pickup_date Borough trip_count avg_fare_amount avg_trip_distance avg_trip_duration_minutes total_revenue
0 2024-01-01 Bronx 76 26.79 4.51 18.78 2317.19
1 2024-01-01 Brooklyn 646 27.45 4.41 19.67 22562.33
2 2024-01-01 EWR 20 78.48 0.86 0.61 2015.52
3 2024-01-01 Manhattan 59000 17.06 2.87 14.87 1467296.17
4 2024-01-01 Queens 9174 53.79 13.04 27.40 661604.49
5 2024-01-01 Staten Island 3 96.40 5.87 12.74 342.94
6 2024-01-01 Unknown 371 40.06 3.44 19.39 18225.87Seven real rows — one per borough that had trips pickup on 2024-01-01 — down from 81,013 raw rows and 69,290 clean ones. trip_count sums to exactly 69,290, matching validate_trips’s own rows_passed, because transform_daily_borough_summary asserted that invariant before ever writing the file.
Key Takeaways
- Real data-quality checks report which check failed and how many rows, not just a single pass/fail bit —
validation_report.json’schecksbreakdown is what makes 11,723 failures actionable instead of just alarming. - A quarantine file (the actual bad rows) is as important as the report (the counts) — someone eventually has to go look at why 10,518 rows were missing
passenger_count, and the report alone can’t answer that. transform_daily_borough_summarynever trustsvalidate_trips’s numbers blindly — it re-assertslen(clean) == rows_passedandtotal_trip_count == rows_passedas real, executable invariants, not comments.- An 85.53% pass rate on real data is more trustworthy evidence the checks are real than a 100% pass rate would be.
Exercises
Exercise 1 — Add a sixth check, total_amount_less_than_fare_amount (flagging any row where total_amount < fare_amount, which shouldn’t be possible since total_amount includes fare plus tips/tolls/fees), and re-trigger. How many real rows does it catch?
Exercise 2 — Open quarantine_trips.csv after a real run and compute, using pandas, what fraction of the quarantined rows failed only the null_required_fields check (as opposed to failing that one plus another). Is it closer to 100% or notably lower?
Exercise 3 — Change MIN_PASS_RATE to 0.9 (above the real 0.8553 this lesson’s data produces) and re-trigger. Confirm validate_trips now genuinely fails, and that transform_daily_borough_summary shows upstream_failed — the same mechanism Lesson 1 proved, now enforced by a real number instead of a forced flag.