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.
- Extract incrementally with a processed-files manifest saved atomically
- Validate with a rule engine that reports every reason a row is rejected
- Normalize naive local and offset timestamps to UTC with
zoneinfo, and compute correct durations across DST - Model a star schema (
dim_stationSCD2,dim_date,fact_trip) and load it idempotently - Track a watermark, count late-arriving data, and prove that incremental runs equal one full load
- Orchestrate with a
graphlibDAG with retries, and answer questions with window-function SQL
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:
- Files alternate between CSV with naive local timestamps (
2026-03-05 08:15:00, meaning America/New_York) and JSON Lines with UTC offsets (2026-03-06T08:15:00-05:00). - Every file has an exact duplicate row, an unparseable timestamp, a trip to an
unknown station (
S99), and a trip whose end is before its start. - From day two, each file carries two late-arriving trips from the previous day; from day three, it also re-delivers a trip already sent.
- On 2026-03-08 a new station snapshot arrives: Harbor Point is renamed Harbor Point Plaza, University Ave gets more docks, and Ferry Terminal opens. The same night, clocks spring forward.
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
| Module | Public API | Behaviour |
|---|---|---|
models.py (given) | Station, Trip, Rejection, RunSummary, LOCAL_TZ, TRIP_FIELDS | Frozen 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.py | Manifest, discover_new_files, read_trip_file, read_station_file, snapshot_date | String-valued rows with _source; malformed JSON lines become _error rows; atomic manifest save |
validate.py | parse_timestamp, Rule, default_rules, validate, reason_counts, write_rejection_report | All 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.py | normalize, dedupe, enrich, transform | UTC times, duration_s, local local_date; first occurrence of a trip_id wins |
warehouse.py | connect, date_key, ensure_dates, apply_station_snapshot, current_stations, station_key_at, load_trips, get_watermark, set_watermark, table_count | Schema given as SCHEMA; SCD2; point-in-time lookup; upserts in one transaction; watermark never moves back |
dag.py | retry, Task, DAG.add/order/run, TaskFailedError | graphlib order, per-task retries, downstream tasks skipped after a failure |
analytics.py | daily_trips(conn, window=7), top_routes(conn, limit=5) | Window-function SQL; routes grouped by station id, labelled with current names |
pipeline.py | build_dag, run_pipeline(raw_dir, db_path, state_dir), main | Returns 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.loadof a missing file returns an empty manifest;markdoesn't write;savewrites a sorted list via a temp file andreplacediscover_new_filesskips processed names and sorts by name (names embed ISO dates)read_trip_filereturns 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
Ruleobjects can be added; valid rows keep input order reason_countsand the appending JSON Lineswrite_rejection_reportwork
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_date2026-03-05 even though its UTC date is 2026-03-06 - The CSV and JSONL forms of the same trip normalize to equal
Tripobjects dedupekeeps the first occurrence and counts the rest;enrichusesdataclasses.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_snapshotinserts new stations, closes and re-opens changed ones, ignores identical or older snapshotsstation_key_atfinds the version wherevalid_from <= day < valid_to(or open-ended), falling back to the earliestload_tripsfillsdim_date, resolves station keys, and upserts in one transaction; an unknown station raisesKeyErrorand leaves no partial rows- The watermark only moves forward;
table_countwhitelists 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()usesgraphlib.TopologicalSorter, raisesValueErrorfor unknown dependencies, and letsCycleErrorpropagaterun()passes the results dict to each task, retries up toretriesextra times, and raisesTaskFailedError(task, attempts)chainedfromthe last errorretryretries only the listed exceptions and preserves the function name withfunctools.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 == 2on day two) and land on their real business day - Trips before the rename reference "Harbor Point"; trips after reference "Harbor Point Plaza"
daily_tripsreturns a 7-day moving average withAVG() OVER (ORDER BY day ROWS BETWEEN 6 PRECEDING AND CURRENT ROW);top_routesusesRANK()
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
| Criterion | Excellent | Weight |
|---|---|---|
| Correctness | All 63 tests pass; the CLI prints a sensible summary on fresh and repeated runs | 40% |
| Idempotency and incrementality | Re-runs, reprocessing, and daily batches all give identical warehouses; state is saved atomically and only after commits | 20% |
| Data quality | Every rejected row carries all its reasons; nothing is silently dropped; the report is readable | 15% |
| Modeling and SQL | Clear grain, SCD2 with point-in-time lookups, parameterized queries, readable CTE-based analytics | 15% |
| Code quality | Typed, documented, ruff-clean (pixi run ruff check work/p09_etl_pipeline); structured logs instead of prints in library code | 10% |
Stretch goals
- Quarantine table: also load rejected rows into
rejected_trip(source, reasons_json, row_json)so analysts can query them. - Allowed lateness: reject trips older than the watermark minus two days with a
too_latereason, and test the boundary. - Data contract: write the raw trip contract as a JSON schema, version it, and fail the run on an incompatible change.
- DuckDB export:
pixi add duckdb, then attach the SQLite warehouse (INSTALL sqlite; ATTACH 'warehouse.db' (TYPE sqlite)) and writefact_tripto Parquet partitioned byyear=/month=; re-run the analytics in DuckDB. - Lineage: emit an OpenLineage-shaped JSON event per DAG task with inputs, outputs, and row counts.
- Orchestrator: wrap
run_pipelinein a Prefect flow or Dagster assets with a daily schedule and a backfill command.
Reflection quiz
When you're done, carry these skills into the Capstone, where the pipeline feeds a real application.