Skip to content

Commit bb8657d

Browse files
lassebenniLasse Benningaclaude
authored
Restructure Week 9 practice exercises & add Exercise 8 (fan-out) (#33)
* fix(data-track/week-9): clarify validation expectations, deterministic sample, assignment cast note Polish from a Week 9 exercise review (all solutions live-verified against the shared Azure Postgres: Manhattan top, 0 NULL pickups, 189 duplicate groups, 0 orphans, fact view 56,369 = 56,551 - 182 negatives). - Ex1: add ORDER BY pickup_datetime before LIMIT 5 so the sample is stable between runs (a bare LIMIT returns an arbitrary 5 rows). - Ex5: tell students 5a returns 0 and 5c returns empty BY DESIGN (pickup IDs are complete; orphans do not exist on this load). A 0 is the check passing, not a mistake. Prevents the "did I break it?" confusion. - Ex6: note the practice view is scaled down; the assignment's vw_fact_trips also casts pickup_datetime::TIMESTAMP. Flag it so students do not under-build the assignment deliverable. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Fix double precision to numeric type casting issue for ROUND() in week 9 exercise solutions * Restructure Week 9 practice: move EXPLAIN to Ex5, shift subsequent exercises, and add Exercise 8 (Deduplicate Fan-out Join) --------- Co-authored-by: Lasse Benninga <devops.pipeline@example.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent e365b43 commit bb8657d

13 files changed

Lines changed: 258 additions & 154 deletions

File tree

data-track/week-9/README.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
# HYF Data Track — Week 9 Practice Exercises
22

3-
Seven SQL exercises that consolidate Week 9 (SQL for Analytics): joins, CTEs, aggregations, data validation, and building views. They are the runnable counterpart to the in-chapter practice. Run them against your own schema on the shared Azure PostgreSQL instance, not the shared `public` schema.
3+
Eight SQL exercises that consolidate Week 9 (SQL for Analytics): joins, CTEs, aggregations, data validation, and building views. They are the runnable counterpart to the in-chapter practice. Run them against your own schema on the shared Azure PostgreSQL instance, not the shared `public` schema.
44

55
The dataset is the same NYC Taxi data you used in the chapters: `nyc_taxi.raw_trips` (~57K green-taxi rides from January 2024) and `nyc_taxi.raw_zones` (265 location lookups).
66

@@ -12,9 +12,10 @@ The dataset is the same NYC Taxi data you used in the chapters: `nyc_taxi.raw_tr
1212
| `exercise_2` | Trips and average fare per borough | GROUP BY with COUNT and AVG |
1313
| `exercise_3` | Find the busiest day with a CTE | WITH block, then query the CTE |
1414
| `exercise_4` | Refactor a nested subquery into CTEs | Rewrite nested subqueries as named steps |
15-
| `exercise_5` | Validate the raw data | NULL checks, duplicate detection, orphan detection |
16-
| `exercise_6` | Build views, then query them | CREATE OR REPLACE VIEW and star-schema queries |
17-
| `exercise_7` | Compare a cartesian join to a filtered join | EXPLAIN and query plans |
15+
| `exercise_5` | Compare a cartesian join to a filtered join | EXPLAIN and query plans |
16+
| `exercise_6` | Validate the raw data | NULL checks, duplicate detection, orphan detection |
17+
| `exercise_7` | Build views, then query them | CREATE OR REPLACE VIEW and star-schema queries |
18+
| `exercise_8` | Detect and fix join fan-out | Spot double-counting, unique key checks, deduplication |
1819

1920
## Folder structure
2021

@@ -30,7 +31,7 @@ week-9/
3031
README.md
3132
solutions/
3233
exercise.sql
33-
... (through exercise_7)
34+
... (through exercise_8)
3435
```
3536

3637
## How to run
Lines changed: 4 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1,19 +1,15 @@
1-
# Exercise 5: Validate the raw data
1+
# Exercise 5 (stretch): Compare a cartesian join to a filtered join
22

33
## What you do
44

5-
You write three data-quality checks that the Week 9 assignment's audit task expects, each as its own query:
6-
7-
- 5a: count trips with a NULL `pickup_location_id`.
8-
- 5b: find duplicate trips, rows that share the same `vendor_id`, `pickup_datetime`, and `dropoff_datetime`.
9-
- 5c: find orphaned pickup IDs, `pickup_location_id` values that do not exist in `nyc_taxi.raw_zones`.
5+
A forgotten join condition produces a cartesian product: every trip paired with every zone. You use `EXPLAIN` to see how the query planner treats two versions differently, the cartesian `CROSS JOIN` with no `ON` clause, and the filtered `INNER JOIN` on `pickup_location_id = location_id`, without actually running the expensive one. Then you compare the estimated row counts at the top node of each plan.
106

117
## How to run
128

13-
Open `exercise.sql` and run each of the three queries against your own schema on the shared Azure PostgreSQL, using psql or any SQL client. These queries only read data, they do not modify anything.
9+
Open `exercise.sql` and run both `EXPLAIN` queries against your own schema on the shared Azure PostgreSQL, using psql or any SQL client. `EXPLAIN` only prints the plan, it does not execute the query or modify any data.
1410

1511
## Success criteria
1612

17-
Each check returns a clear answer. For 5a you get a single count, and on this dataset that count is **0**: the pickup location IDs are complete. That is the point of the check, not a sign you wrote it wrong. The real issues live elsewhere: 5b surfaces duplicate trips, and the negative-fare and NULL `payment_type` problems show up in the chapter. For 5b and 5c, an empty result means the check passed: any rows returned are the problems to report. 5c also comes back empty here (every pickup ID resolves to a zone), which is the correct passing outcome, not a missed bug.
13+
The cartesian plan estimates roughly 57,000 times 265 rows (around 15 million) at its top node, while the filtered plan estimates about one matched zone per trip. That gap is why a forgotten `ON` clause can hang your session.
1814

1915
Stuck? The reference queries are in `solutions/exercise.sql`, try for 10 to 20 minutes first.
Lines changed: 14 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -1,29 +1,22 @@
1-
-- Exercise 5: Validate the raw data
1+
-- Exercise 7 (stretch): Compare a cartesian join to a filtered join
22
--
3-
-- Raw data is rarely clean. Write three checks that the assignment's audit task
4-
-- expects you to run:
5-
-- 5a. Count trips with a NULL pickup_location_id.
6-
-- 5b. Find duplicate trips: rows that share the same vendor_id, pickup_datetime,
7-
-- and dropoff_datetime.
8-
-- 5c. Find orphaned pickup IDs: pickup_location_id values in nyc_taxi.raw_trips that do
9-
-- not exist in nyc_taxi.raw_zones.
3+
-- A missing join condition produces a cartesian product: every trip matched to
4+
-- every zone. Use EXPLAIN to see how the planner treats the two queries
5+
-- differently, without actually running the expensive one.
6+
--
7+
-- Run EXPLAIN on both versions and compare the estimated row counts at the top node.
108
--
119
-- Dataset: nyc_taxi.raw_trips (~57K green-taxi rows, Jan 2024) and nyc_taxi.raw_zones (265 rows).
1210
-- Run these against your OWN schema on the shared Azure PostgreSQL, not public.
1311
--
14-
-- Hint: For duplicates, GROUP BY the three columns and keep groups with
15-
-- HAVING COUNT(*) > 1. For orphans, a LEFT JOIN to nyc_taxi.raw_zones with
16-
-- WHERE z.location_id IS NULL surfaces the unmatched IDs.
17-
18-
-- 5a. Trips with a missing pickup location
19-
-- TODO: count rows where pickup_location_id IS NULL.
20-
-- Expect 0 here: the pickup IDs are complete. A 0 is the check passing,
21-
-- not a mistake. The real dirt is duplicates (5b) and negative fares.
22-
12+
-- Hint: EXPLAIN shows the plan and its cost estimate without executing the query.
13+
-- The cartesian version has no ON clause; the filtered version joins on
14+
-- pickup_location_id = location_id. Compare the rows= estimate on the top
15+
-- line of each plan.
2316

24-
-- 5b. Duplicate trips (same vendor + pickup + dropoff time)
25-
-- TODO: group by the three columns and keep groups with more than one row.
17+
-- Cartesian product: no join condition
18+
-- TODO: EXPLAIN a CROSS JOIN of nyc_taxi.raw_trips and nyc_taxi.raw_zones (no ON clause).
2619

2720

28-
-- 5c. Orphaned pickup IDs not present in nyc_taxi.raw_zones
29-
-- TODO: LEFT JOIN nyc_taxi.raw_trips to nyc_taxi.raw_zones and keep rows with no matching zone.
21+
-- Filtered join: one zone per trip
22+
-- TODO: EXPLAIN an INNER JOIN of nyc_taxi.raw_trips and nyc_taxi.raw_zones on pickup_location_id = location_id.
Lines changed: 12 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,31 +1,18 @@
1-
-- Exercise 5 solution: Validate the raw data
1+
-- Exercise 7 solution (stretch): Compare a cartesian join to a filtered join
22

3-
-- 5a. Trips with a missing pickup location
4-
SELECT COUNT(*) AS null_pickup_location
3+
-- Cartesian product: no join condition, ~57K trips x 265 zones
4+
EXPLAIN -- WHY EXPLAIN (not EXPLAIN ANALYZE): EXPLAIN only estimates and prints the plan; it never runs the query, so the ~15M-row cartesian product never actually materializes and cannot hang your session
5+
SELECT *
56
FROM nyc_taxi.raw_trips t
6-
WHERE t.pickup_location_id IS NULL;
7-
-- WHY IS NULL (not = NULL): in SQL, NULL is never equal to anything, even NULL.
8-
-- You must test for absence with IS NULL, not with = NULL, or the filter matches nothing.
7+
CROSS JOIN nyc_taxi.raw_zones z; -- WHY CROSS JOIN: with no ON clause the planner has no way to pair rows, so it produces every trip x every zone combination
98

109

11-
-- 5b. Duplicate trips (same vendor + pickup + dropoff time)
12-
SELECT
13-
t.vendor_id,
14-
t.pickup_datetime,
15-
t.dropoff_datetime,
16-
COUNT(*) AS copies
10+
-- Filtered join: one zone per trip
11+
EXPLAIN
12+
SELECT *
1713
FROM nyc_taxi.raw_trips t
18-
GROUP BY t.vendor_id, t.pickup_datetime, t.dropoff_datetime
19-
HAVING COUNT(*) > 1 -- WHY HAVING (not WHERE): WHERE filters individual rows before grouping; HAVING filters the GROUPS after aggregation. COUNT(*) only exists per group, so it must be tested in HAVING.
20-
ORDER BY copies DESC;
14+
INNER JOIN nyc_taxi.raw_zones z
15+
ON t.pickup_location_id = z.location_id; -- WHY the ON clause matters: it tells the planner each trip matches exactly one zone, so the estimate collapses from millions to roughly the trip count
2116

22-
23-
-- 5c. Orphaned pickup IDs not present in nyc_taxi.raw_zones
24-
SELECT DISTINCT t.pickup_location_id
25-
FROM nyc_taxi.raw_trips t
26-
LEFT JOIN nyc_taxi.raw_zones z -- WHY LEFT JOIN: keeps every trip even when no zone matches; an INNER JOIN would silently drop the unmatched (orphan) rows we are hunting for
27-
ON t.pickup_location_id = z.location_id
28-
WHERE z.location_id IS NULL -- WHY IS NULL here: after a LEFT JOIN, an unmatched trip has NULL zone columns; filtering on z.location_id IS NULL isolates exactly the orphans
29-
ORDER BY t.pickup_location_id;
30-
31-
-- An empty result for 5b or 5c means the check passed. Any rows returned are the problems to report.
17+
-- WHY this comparison teaches: the cartesian plan estimates roughly 57,000 x 265 (around 15 million),
18+
-- while the filtered plan estimates about one matched zone per trip. That gap is why a forgotten ON clause can hang your session.
Lines changed: 7 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,19 @@
1-
# Exercise 6: Build views, then query them
1+
# Exercise 6: Validate the raw data
22

33
## What you do
44

5-
You wrap the cleaned-up logic in two views, then query them. This is the star-schema deliverable the Week 9 assignment asks for, scaled down to practice it once:
5+
You write three data-quality checks that the Week 9 assignment's audit task expects, each as its own query:
66

7-
- 6a: create `vw_dim_zones` from `nyc_taxi.raw_zones` and `vw_fact_trips` from `nyc_taxi.raw_trips`, excluding rows where `fare_amount` is negative.
8-
- 6b: using your views, find which borough had the highest total fare revenue.
9-
- 6c: using your views, find the top 5 pickup zones by trip count.
7+
- 6a: count trips with a NULL `pickup_location_id`.
8+
- 6b: find duplicate trips, rows that share the same `vendor_id`, `pickup_datetime`, and `dropoff_datetime`.
9+
- 6c: find orphaned pickup IDs, `pickup_location_id` values that do not exist in `nyc_taxi.raw_zones`.
1010

1111
## How to run
1212

13-
Open `exercise.sql` and run it against your own schema on the shared Azure PostgreSQL, using psql or any SQL client. Unlike the other exercises, 6a creates two views in your own schema. It uses `CREATE OR REPLACE VIEW`, so it is safe to re-run while you iterate. Steps 6b and 6c only read from those views.
13+
Open `exercise.sql` and run each of the three queries against your own schema on the shared Azure PostgreSQL, using psql or any SQL client. These queries only read data, they do not modify anything.
1414

1515
## Success criteria
1616

17-
After 6a, both views exist in your schema and can be queried. 6b returns the single highest-revenue borough (Manhattan), and 6c returns five pickup zones ranked by trip count.
18-
19-
> This practice view is deliberately scaled down. The assignment's `vw_fact_trips` also casts `pickup_datetime` to a `TIMESTAMP` (`pickup_datetime::TIMESTAMP`). Add that cast when you build the assignment version, or your fact view will not match the deliverable.
17+
Each check returns a clear answer. For 6a you get a single count, and on this dataset that count is **0**: the pickup location IDs are complete. That is the point of the check, not a sign you wrote it wrong. The real issues live elsewhere: 6b surfaces duplicate trips, and the negative-fare and NULL `payment_type` problems show up in the chapter. For 6b and 6c, an empty result means the check passed: any rows returned are the problems to report. 6c also comes back empty here (every pickup ID resolves to a zone), which is the correct passing outcome, not a missed bug.
2018

2119
Stuck? The reference queries are in `solutions/exercise.sql`, try for 10 to 20 minutes first.
Lines changed: 19 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,32 +1,29 @@
1-
-- Exercise 6: Build views, then query them
1+
-- Exercise 5: Validate the raw data
22
--
3-
-- Wrap the cleaned-up logic in two views, then query them. This is exactly the
4-
-- star-schema deliverable the assignment asks for, scaled down to practice once.
5-
-- 6a. Create vw_dim_zones from nyc_taxi.raw_zones and vw_fact_trips from nyc_taxi.raw_trips,
6-
-- excluding rows where fare_amount is negative.
7-
-- 6b. Using your views, find which borough had the highest total fare revenue.
8-
-- 6c. Using your views, find the top 5 pickup zones by trip count.
3+
-- Raw data is rarely clean. Write three checks that the assignment's audit task
4+
-- expects you to run:
5+
-- 5a. Count trips with a NULL pickup_location_id.
6+
-- 5b. Find duplicate trips: rows that share the same vendor_id, pickup_datetime,
7+
-- and dropoff_datetime.
8+
-- 5c. Find orphaned pickup IDs: pickup_location_id values in nyc_taxi.raw_trips that do
9+
-- not exist in nyc_taxi.raw_zones.
910
--
1011
-- Dataset: nyc_taxi.raw_trips (~57K green-taxi rows, Jan 2024) and nyc_taxi.raw_zones (265 rows).
1112
-- Run these against your OWN schema on the shared Azure PostgreSQL, not public.
12-
-- NOTE: 6a creates views in YOUR OWN schema. CREATE OR REPLACE VIEW is safe to re-run.
13-
-- NOTE: this practice view is scaled down. The Week 9 assignment's vw_fact_trips
14-
-- also casts pickup_datetime to a TIMESTAMP (pickup_datetime::TIMESTAMP).
15-
-- Add that cast when you build the assignment version.
1613
--
17-
-- Hint: A view is a saved query: CREATE VIEW name AS SELECT ... The borough and
18-
-- zone names live in vw_dim_zones, so join
19-
-- vw_fact_trips.pickup_location_id = vw_dim_zones.location_id for any
20-
-- name-level breakdown.
14+
-- Hint: For duplicates, GROUP BY the three columns and keep groups with
15+
-- HAVING COUNT(*) > 1. For orphans, a LEFT JOIN to nyc_taxi.raw_zones with
16+
-- WHERE z.location_id IS NULL surfaces the unmatched IDs.
2117

22-
-- 6a. The two views
23-
-- TODO: CREATE OR REPLACE VIEW vw_dim_zones from nyc_taxi.raw_zones.
24-
-- TODO: CREATE OR REPLACE VIEW vw_fact_trips from nyc_taxi.raw_trips, excluding negative fares.
18+
-- 5a. Trips with a missing pickup location
19+
-- TODO: count rows where pickup_location_id IS NULL.
20+
-- Expect 0 here: the pickup IDs are complete. A 0 is the check passing,
21+
-- not a mistake. The real dirt is duplicates (5b) and negative fares.
2522

2623

27-
-- 6b. Highest total fare revenue by borough
28-
-- TODO: join the two views, sum fare per borough, and return the top borough.
24+
-- 5b. Duplicate trips (same vendor + pickup + dropoff time)
25+
-- TODO: group by the three columns and keep groups with more than one row.
2926

3027

31-
-- 6c. Top 5 pickup zones by trip count
32-
-- TODO: join the two views, count trips per zone, and return the top 5 zones.
28+
-- 5c. Orphaned pickup IDs not present in nyc_taxi.raw_zones
29+
-- TODO: LEFT JOIN nyc_taxi.raw_trips to nyc_taxi.raw_zones and keep rows with no matching zone.
Lines changed: 24 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,31 @@
1-
-- Exercise 6 solution: Build views, then query them
1+
-- Exercise 5 solution: Validate the raw data
22

3-
-- 6a. The two views
4-
CREATE OR REPLACE VIEW vw_dim_zones AS -- WHY CREATE OR REPLACE: lets you re-run this script while iterating without first DROPping the view; a plain CREATE VIEW would error if the view already exists
5-
SELECT
6-
location_id,
7-
borough,
8-
zone
9-
FROM nyc_taxi.raw_zones;
10-
11-
CREATE OR REPLACE VIEW vw_fact_trips AS
12-
SELECT *
13-
FROM nyc_taxi.raw_trips
14-
WHERE fare_amount >= 0; -- WHY filter in the view: the cleaning rule (no negative fares) lives in one place, so every query against the view is automatically clean
3+
-- 5a. Trips with a missing pickup location
4+
SELECT COUNT(*) AS null_pickup_location
5+
FROM nyc_taxi.raw_trips t
6+
WHERE t.pickup_location_id IS NULL;
7+
-- WHY IS NULL (not = NULL): in SQL, NULL is never equal to anything, even NULL.
8+
-- You must test for absence with IS NULL, not with = NULL, or the filter matches nothing.
159

1610

17-
-- 6b. Highest total fare revenue by borough
11+
-- 5b. Duplicate trips (same vendor + pickup + dropoff time)
1812
SELECT
19-
d.borough,
20-
ROUND(SUM(f.fare_amount)::numeric, 2) AS total_revenue -- WHY SUM: revenue is the total of all fares in the borough, not an average
21-
FROM vw_fact_trips f
22-
INNER JOIN vw_dim_zones d -- WHY join to the dim view: borough names live in the dimension, not the fact, so we join to get the breakdown label
23-
ON f.pickup_location_id = d.location_id
24-
GROUP BY d.borough
25-
ORDER BY total_revenue DESC
26-
LIMIT 1; -- WHY LIMIT 1: the question asks for the single highest-revenue borough
13+
t.vendor_id,
14+
t.pickup_datetime,
15+
t.dropoff_datetime,
16+
COUNT(*) AS copies
17+
FROM nyc_taxi.raw_trips t
18+
GROUP BY t.vendor_id, t.pickup_datetime, t.dropoff_datetime
19+
HAVING COUNT(*) > 1 -- WHY HAVING (not WHERE): WHERE filters individual rows before grouping; HAVING filters the GROUPS after aggregation. COUNT(*) only exists per group, so it must be tested in HAVING.
20+
ORDER BY copies DESC;
2721

2822

29-
-- 6c. Top 5 pickup zones by trip count
30-
SELECT
31-
d.zone,
32-
COUNT(*) AS trips
33-
FROM vw_fact_trips f
34-
INNER JOIN vw_dim_zones d
35-
ON f.pickup_location_id = d.location_id
36-
GROUP BY d.zone
37-
ORDER BY trips DESC
38-
LIMIT 5; -- WHY LIMIT 5: the question asks for the top 5 zones by trip count
23+
-- 5c. Orphaned pickup IDs not present in nyc_taxi.raw_zones
24+
SELECT DISTINCT t.pickup_location_id
25+
FROM nyc_taxi.raw_trips t
26+
LEFT JOIN nyc_taxi.raw_zones z -- WHY LEFT JOIN: keeps every trip even when no zone matches; an INNER JOIN would silently drop the unmatched (orphan) rows we are hunting for
27+
ON t.pickup_location_id = z.location_id
28+
WHERE z.location_id IS NULL -- WHY IS NULL here: after a LEFT JOIN, an unmatched trip has NULL zone columns; filtering on z.location_id IS NULL isolates exactly the orphans
29+
ORDER BY t.pickup_location_id;
3930

40-
-- Because these use CREATE OR REPLACE VIEW, you can re-run them safely while you iterate, without dropping the view first.
31+
-- An empty result for 5b or 5c means the check passed. Any rows returned are the problems to report.

0 commit comments

Comments
 (0)