Part 9 · Data Engineering · Graded project

Project 9: ETL Pipeline & Warehouse

Build the nightly data pipeline for a city bike-share: read messy daily trip files, validate every row with reasons, normalize time zones across a daylight-saving switch, and load a sqlite3 star schema with SCD type 2 station history, idempotent upserts, an incremental watermark, and a retrying DAG runner. Sixty-three tests tell you when each piece is right.

⏱ ~10–12 hoursPrerequisites: M60, M61, M62, M63

The scenario

Spokes runs 9 docking stations and a few hundred bikes. Every night each station uploads the day's trips. You are the company's first data engineer, and the operations lead has two questions: "How many trips do we do per day, smoothed over a week?" and "Which routes are busiest, so we can rebalance bikes?" Before you can answer, you have to survive the data:

The deterministic generator (etl/generator.py, complete in the starter) writes seven days of this: 255 raw trip rows and two station snapshots. A correct pipeline loads exactly 222 trips, rejects 21 rows with reasons, removes 12 duplicates, and keeps 11 station versions, whether it runs once or seven times.

Learning goals

Incremental extraction

A manifest of processed files, saved only after the load commits, so re-runs skip finished work and crashes retry unfinished work (M62).

Validation with reasons

Rules return reason codes; every rejected row goes to a JSON Lines report so the source team can fix it (M62).

Time done right

UTC instants for storage, local calendar dates for the business day, and durations computed in UTC so the DST jump doesn't add a phantom hour.

Dimensional modeling

A star schema at the grain "one row per trip", with an SCD type 2 station dimension and point-in-time key lookups (M60).

Idempotency

INSERT … ON CONFLICT DO UPDATE and no-op SCD2 re-applies make the whole pipeline safe to re-run (M61, M62).

Analytics SQL

CTEs, AVG() OVER moving averages, and RANK() over the warehouse you built (M61).

Feature specification

ModulePublic APIBehaviour
models.py (given)Station, Trip, Rejection, RunSummary, LOCAL_TZ, TRIP_FIELDSFrozen dataclasses shared by every stage
generator.py (given)generate(raw_dir, *, start, days, trips_per_day, seed)Writes trips_YYYY-MM-DD.csv|jsonl and stations_YYYY-MM-DD.csv
extract.pyManifest, discover_new_files, read_trip_file, read_station_file, snapshot_dateString-valued rows with _source; malformed JSON lines become _error rows; atomic manifest save
validate.pyparse_timestamp, Rule, default_rules, validate, reason_counts, write_rejection_reportAll reasons per row: missing:<f>, bad_timestamp:<f>, end_before_start, duration_too_long, bad_member_type, unknown_station:<f>, bad_json:line<N>
transform.pynormalize, dedupe, enrich, transformUTC times, duration_s, local local_date; first occurrence of a trip_id wins
warehouse.pyconnect, date_key, ensure_dates, apply_station_snapshot, current_stations, station_key_at, load_trips, get_watermark, set_watermark, table_countSchema given as SCHEMA; SCD2; point-in-time lookup; upserts in one transaction; watermark never moves back
dag.pyretry, Task, DAG.add/order/run, TaskFailedErrorgraphlib order, per-task retries, downstream tasks skipped after a failure
analytics.pydaily_trips(conn, window=7), top_routes(conn, limit=5)Window-function SQL; routes grouped by station id, labelled with current names
pipeline.pybuild_dag, run_pipeline(raw_dir, db_path, state_dir), mainReturns a RunSummary; writes state/manifest.json and state/rejected.jsonl

Architecture

flowchart LR
  subgraph raw[raw/ files]
    T[trips_*.csv / *.jsonl]
    S[stations_*.csv]
  end
  subgraph dag[DAG runner: graphlib + retries]
    direction LR
    st[stations] --> ex[extract] --> va[validate] --> tr[transform] --> lo[load] --> su[summary]
  end
  S --> st
  T --> ex
  va -- rejected rows --> R[(state/rejected.jsonl)]
  lo -- after commit --> M[(state/manifest.json)]
  st --> W
  lo --> W[(warehouse.db)]
  su --> RS[RunSummary]
    
erDiagram
  DIM_DATE ||--o{ FACT_TRIP : date_key
  DIM_STATION ||--o{ FACT_TRIP : start_station_key
  DIM_STATION ||--o{ FACT_TRIP : end_station_key
  FACT_TRIP {
    text trip_id PK
    int date_key FK
    int start_station_key FK
    int end_station_key FK
    text start_utc
    text end_utc
    int duration_s
    text bike_id
    text member_type
  }
  DIM_STATION {
    int station_key PK
    text station_id
    text name
    int capacity
    text valid_from
    text valid_to
    int is_current
  }
  DIM_DATE {
    int date_key PK
    text date
    int year
    int month
    int day
    text weekday
    int is_weekend
  }
  ETL_STATE {
    text key PK
    text value
  }
    

The grain of fact_trip is one row per trip, keyed by the natural key trip_id. Facts reference the station version in effect on the trip's local date, so a trip on 2026-03-07 shows "Harbor Point" forever, while one on 2026-03-08 shows "Harbor Point Plaza".

Getting started

pixi run projects                    # list projects
pixi run start p09                   # copy the starter to work/p09_etl_pipeline/
pixi run grade p09                   # run all 63 tests against your work/ copy
pixi run practicum grade p09 -k extract    # one milestone (extra arguments go to pytest)
cd work/p09_etl_pipeline
pixi run python -m etl.pipeline raw warehouse.db state --generate   # once everything passes

The starter's generator.py and models.py are complete; everything else has its real signature and docstring, a body of raise NotImplementedError, and a # TODO describing the approach. Some imports were trimmed, so add what you need as you go. Explore the data first:

pixi run python -c "from pathlib import Path; from etl.generator import generate; generate(Path('raw'))"
head -5 raw/trips_2026-03-05.csv raw/trips_2026-03-06.jsonl raw/stations_2026-03-08.csv

Milestones

Milestone 1 · Extract (test_etl_extract.py, 9 tests)

Implement Manifest (load, is_processed, mark, processed, atomic save), discover_new_files, both readers, and snapshot_date.

  • Manifest.load of a missing file returns an empty manifest; mark doesn't write; save writes a sorted list via a temp file and replace
  • discover_new_files skips processed names and sorts by name (names embed ISO dates)
  • read_trip_file returns string values (None → "") plus _source, skips blank JSONL lines, and turns malformed lines into {"_error": "bad_json:line<N>"}
  • Unknown extensions raise ValueError
Hint: one shape for two formats

Normalize at the edge: after csv.DictReader or json.loads, build {k: "" if v is None else str(v) for k, v in record.items()} | {"_source": path.name}. Then nothing downstream needs to know which format a row came from.

Milestone 2 · Validate (test_etl_validate.py, 16 tests)

Implement parse_timestamp and the rule engine. Every rule runs on every row, and a rejected row carries all its reasons.

  • Naive timestamps are interpreted in America/New_York; offset timestamps keep their offset; both become UTC
  • Each reason code from the spec is produced by the right rule
  • Custom Rule objects can be added; valid rows keep input order
  • reason_counts and the appending JSON Lines write_rejection_report work
Hint: parse once, fail gracefully

datetime.fromisoformat parses both 2026-03-05 08:00:00 and 2026-03-05T08:00:00-05:00 in Python 3.11+. Check moment.tzinfo is None to decide whether to attach LOCAL_TZ, then astimezone(UTC). In the timestamp rule, catch only ValueError.

Milestone 3 · Transform (test_etl_transform.py, 8 tests)

Implement normalize, dedupe, enrich, and transform.

  • A trip from 01:30 to 03:30 local on 2026-03-08 lasts 3600 seconds, not 7200
  • A trip at 21:30 local on 2026-03-05 has local_date 2026-03-05 even though its UTC date is 2026-03-06
  • The CSV and JSONL forms of the same trip normalize to equal Trip objects
  • dedupe keeps the first occurrence and counts the rest; enrich uses dataclasses.replace
Hint: why the DST test passes for free

Subtracting two aware datetimes that share the same ZoneInfo object uses wall-clock arithmetic and would report two hours. Because parse_timestamp already converted both ends to UTC, the subtraction measures real elapsed time.

Milestone 4 · Load the warehouse (test_etl_warehouse.py, 12 tests)

Implement the warehouse functions on top of the given SCHEMA.

  • apply_station_snapshot inserts new stations, closes and re-opens changed ones, ignores identical or older snapshots
  • station_key_at finds the version where valid_from <= day < valid_to (or open-ended), falling back to the earliest
  • load_trips fills dim_date, resolves station keys, and upserts in one transaction; an unknown station raises KeyError and leaves no partial rows
  • The watermark only moves forward; table_count whitelists table names
Hint: SCD2 in three queries

Select the current row (WHERE station_id = ? AND is_current = 1). If absent, insert. If attributes differ and the snapshot date is after the row's valid_from, UPDATE … SET valid_to = ?, is_current = 0 and insert the new version. Wrap the loop in with conn: so a failure rolls everything back.

Hint: ON CONFLICT
INSERT INTO fact_trip (trip_id, date_key, ...) VALUES (?, ?, ...)
ON CONFLICT (trip_id) DO UPDATE SET
    date_key = excluded.date_key,
    duration_s = excluded.duration_s, ...

Milestone 5 · Orchestrate (test_etl_dag.py, 8 tests)

Implement the retry decorator and the DAG class.

  • order() uses graphlib.TopologicalSorter, raises ValueError for unknown dependencies, and lets CycleError propagate
  • run() passes the results dict to each task, retries up to retries extra times, and raises TaskFailedError(task, attempts) chained from the last error
  • retry retries only the listed exceptions and preserves the function name with functools.wraps
Hint: the retry loop

for attempt in range(1, task.retries + 2): try the task and break on success; in except Exception as exc, log, and if attempt > task.retries, raise TaskFailedError(name, attempt) from exc.

Milestone 6 · End to end and analytics (test_etl_pipeline.py, 10 tests)

Wire everything into build_dag and run_pipeline, then write the two analytics queries.

  • A full run reports 255 read, 21 rejected, 12 duplicates removed, 222 loaded, 11 station versions
  • A second run processes nothing; deleting the manifest and re-running leaves the same row counts
  • Seven daily incremental runs end with the same warehouse as one full run
  • Late trips are counted (late_records == 2 on day two) and land on their real business day
  • Trips before the rename reference "Harbor Point"; trips after reference "Harbor Point Plaza"
  • daily_trips returns a 7-day moving average with AVG() OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW); top_routes uses RANK()
Hint: what "late" means here

Read the watermark before loading. A trip is late if its start_utc.isoformat() is at or before that previous watermark. After loading, set the watermark to the batch's maximum start_utc; set_watermark refuses to move it backwards.

Hint: order of DAG tasks

Stations must load before validation (the unknown-station rule needs the known ids) and before trips (facts need station keys). Save the manifest in the load task, after load_trips commits, never earlier.

Rubric

CriterionExcellentWeight
CorrectnessAll 63 tests pass; the CLI prints a sensible summary on fresh and repeated runs40%
Idempotency and incrementalityRe-runs, reprocessing, and daily batches all give identical warehouses; state is saved atomically and only after commits20%
Data qualityEvery rejected row carries all its reasons; nothing is silently dropped; the report is readable15%
Modeling and SQLClear grain, SCD2 with point-in-time lookups, parameterized queries, readable CTE-based analytics15%
Code qualityTyped, documented, ruff-clean (pixi run ruff check work/p09_etl_pipeline); structured logs instead of prints in library code10%

Stretch goals

Reflection quiz

When you're done, carry these skills into the Capstone, where the pipeline feeds a real application.