Grain Rehearsal: Say the Sentence, Then Prove It in SQL
Review declared grain in one screen, then do the new work: audit a raw ride-share extract against its declared grain with executable SQL before any star gets built.
The review screen (you already learned this)
Level 3's lesson "Facts, Dimensions, and Grain" taught the whole idea, so this is a pointer, not a reteach:
- The grain is the sentence that says what one row of a fact table means. "One row per completed trip." "One row per order line." "One row per driver per day."
- Declare it before you draw a single table. Every later decision, which measures are allowed, which dimensions can attach, which duplicates are bugs, falls out of that sentence.
- Facts stay narrow (keys plus measures). Dimensions get wide (descriptive attributes).
- Saying the grain out loud, first, is a named strong-candidate signal in modeling interviews.
That is the part you already have. Here is the part this lesson adds.
The follow-up that separates rehearsed candidates
You say "one row per completed trip." A good interviewer does not say "great." They say: prove the data respects it.
The proof is a query, and it is short. Group the raw extract by the key your grain sentence names, count the rows in each group, and keep only the groups holding more than one. If that query returns nothing, the extract honors the declared grain. If it returns rows, you found duplicates before they inflated a single number.
- Staging extractraw rows, grain unproven
- Declare grainone row per completed trip
- Grain auditGROUP BY key HAVING COUNT(*) > 1
- Dedupkeep the latest row per key
- Clean factgrain now guaranteed
The scenario
A ride-share company hands you staging_trips, a raw extract from the trips service. It is wide: it carries the driver's name and home city on every trip row, which is normal for an extract and wrong for a fact table. It also re-emits corrected rows: when a fare is adjusted, the service sends the whole trip again with a later completed_at instead of updating in place.
So the extract has more rows than trips. Run the audit and you can say which trips, and how badly, in one query.
Repairing the grain
Once the audit finds duplicates, the repair is the windowed-dedup pattern you already know: number the rows inside each key, newest first, and keep number one.
WITH latest AS (
SELECT trip_id, fare_usd,
ROW_NUMBER() OVER (PARTITION BY trip_id ORDER BY completed_at DESC) AS rn
FROM staging_trips
)
SELECT ROUND(SUM(fare_usd), 2) AS clean_revenue
FROM latest
WHERE rn = 1;
PARTITION BY trip_id restarts the numbering inside each trip. ORDER BY completed_at DESC decides which version wins. WHERE rn = 1 keeps exactly one row per trip, which is the declared grain, restored.
Common mistake: deduplicating with SELECT DISTINCT on the whole row. The corrected rows differ in fare_usd and completed_at, so DISTINCT keeps every one of them and reports success. Dedup by the grain key, then pick a winner by a rule you can defend.
Interview nuance: in a take-home, running the grain audit before you build anything is a 60-second move that reviewers notice. It is the difference between "I assumed the extract was clean" and "I checked, and two trips were re-emitted, so I kept the latest version of each." Narrate the rule you chose for picking the winner. That rule is the actual judgment call.
On a real platform this differs. Here the audit is an ad-hoc query you run by hand. In a warehouse it becomes a scheduled test: dbt's
uniquetest on the grain key, or Great Expectations'expect_compound_columns_to_be_unique, both of which fail the build instead of the dashboard. The SQL underneath is the sameGROUP BY key HAVING COUNT(*) > 1you are about to write.
CREATE TABLE staging_trips (
trip_id TEXT,
rider_id TEXT,
driver_id INTEGER,
driver_name TEXT,
driver_city TEXT, -- the driver's home city, repeated on every one of their trip rows
city TEXT, -- the city the trip was requested in
requested_at TEXT,
completed_at TEXT, -- a corrected re-emission of a trip carries a LATER completed_at
fare_usd REAL, -- a correction can move the fare DOWN, so latest-wins <> highest-wins
tip_usd REAL
);
INSERT INTO staging_trips (trip_id, rider_id, driver_id, driver_name, driver_city, city, requested_at, completed_at, fare_usd, tip_usd) VALUES
('T-1001', 'R-501', 42, 'Maya Okafor', 'Detroit', 'Detroit', '2026-06-01 08:12:00', '2026-06-01 08:31:00', 18.40, 3.00),
('T-1002', 'R-502', 42, 'Maya Okafor', 'Detroit', 'Detroit', '2026-06-01 09:02:00', '2026-06-01 09:20:00', 22.10, 0.00),
('T-1002', 'R-502', 42, 'Maya Okafor', 'Detroit', 'Detroit', '2026-06-01 09:02:00', '2026-06-01 09:25:00', 24.60, 0.00),
('T-1003', 'R-503', 51, 'Ivan Petrov', 'Ann Arbor', 'Ann Arbor', '2026-06-01 10:05:00', '2026-06-01 10:19:00', 12.75, 2.50),
('T-1004', 'R-504', 51, 'Ivan Petrov', 'Ann Arbor', 'Ann Arbor', '2026-06-01 11:40:00', '2026-06-01 12:02:00', 31.20, 5.00),
('T-1005', 'R-505', 63, 'Lena Diaz', 'Chicago', 'Chicago', '2026-06-01 13:11:00', '2026-06-01 13:29:00', 27.90, 4.00),
('T-1005', 'R-505', 63, 'Lena Diaz', 'Chicago', 'Chicago', '2026-06-01 13:11:00', '2026-06-01 13:34:00', 29.40, 4.00),
('T-1005', 'R-505', 63, 'Lena Diaz', 'Chicago', 'Chicago', '2026-06-01 13:11:00', '2026-06-01 13:41:00', 26.40, 4.00),
('T-1006', 'R-506', 63, 'Lena Diaz', 'Chicago', 'Chicago', '2026-06-01 14:20:00', '2026-06-01 14:47:00', 44.05, 6.50),
('T-1007', 'R-507', 42, 'Maya Okafor', 'Detroit', 'Detroit', '2026-06-01 15:30:00', '2026-06-01 15:52:00', 16.85, 0.00),
('T-1008', 'R-508', 74, 'Sam Byrne', 'Detroit', 'Detroit', '2026-06-01 16:02:00', '2026-06-01 16:24:00', 19.95, 3.25),
('T-1009', 'R-509', 74, 'Sam Byrne', 'Detroit', 'Detroit', '2026-06-01 17:15:00', '2026-06-01 17:44:00', 38.60, 7.00);-- The grain check itself: how many rows does the extract hold per trip?
SELECT trip_id, COUNT(*) AS row_count
FROM staging_trips
GROUP BY trip_id
ORDER BY row_count DESC, trip_id;Apply
Your turn
The task this lesson builds to.
Write a query that returns every trip violating the declared grain of one row per completed trip, as (trip_id, row_count), worst first, over staging_trips(trip_id, rider_id, driver_id, driver_name, driver_city, city, requested_at, completed_at, fare_usd, tip_usd).
Group by trip_id, count the rows in each group, and keep only groups holding more than one row. Alias the count exactly row_count, ordered by row_count descending then trip_id.
4 hints and 1 automated check are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, plus 2 bonus drills.
Write a query that returns the true total fare revenue as (clean_revenue) rounded to 2 decimals, over the same staging_trips table.
Keep one row per trip_id before summing, and when a trip has several rows the one with the latest completed_at wins. Sum fare_usd over those surviving rows only, and alias the result exactly clean_revenue.
4 hints and 1 automated check are waiting in the workspace.