Semi-Additive Rehearsal: The Wrong Sum and the Right Sum, Side by Side
Review measure additivity in one screen, then grade the new artifact: a single query that shows the naive cross-day SUM next to the correct as-of total so the delta is visible.
The review screen (you already learned this)
Level 4's lesson "Fact Table Types and Measure Additivity" owns the first teaching of this, so here is the compressed version:
- A transaction fact stores events. One row per thing that happened, and its measures are additive: they sum across every dimension including time. A payout of $500 plus a payout of $300 is $800, on any day, in any city.
- A periodic snapshot fact stores state on a schedule. One row per entity per date, and its measures are semi-additive: they sum across entities on a single date, and never across dates. Six drivers' wallet balances on June 30 add up to a real number. One driver's balances on June 10, June 20, and June 30 add up to nothing at all.
- Ratios and percentages are non-additive. You never sum them and you never average an average. Recompute from the parts:
SUM(numerator) / SUM(denominator).
That is the ladder. Naming it is table stakes.
What this lesson adds
Naming the trap is a knowledge answer. Interviewers get that answer from most candidates. What they rarely get is the demonstration: one result set holding the wrong number and the right number side by side, so the size of the error is visible instead of asserted.
| driver_id | as_of_date | balance_usd | counted by the naive SUM | counted by the as-of SUM |
|---|---|---|---|---|
| 42 | 2026-06-10 | 980 | yes | no |
| 42 | 2026-06-20 | 1105.75 | yes | no |
| 42 | 2026-06-30 | 1240.5 | yes | yes |
| 74 | 2026-06-10 | 640.6 | yes | no |
| 74 | 2026-06-20 | 715.4 | yes | no |
| 74 | 2026-06-30 | 860.25 | yes | yes |
The correct total for Detroit is $2,100.75, the two June 30 balances. The naive total counts every snapshot and lands on $5,542.50, about two and a half times the real figure, which is not a wallet balance, not a revenue figure, and not any quantity that exists in the business. The multiple is close to the three snapshots per month but not equal to it, because the balances grow across those snapshots rather than repeating.
Getting to the as-of total
The pattern is the one you used for grain repair, pointed at time instead of duplicates: number each driver's snapshots newest first, keep number one, then sum across drivers.
WITH ranked AS (
SELECT driver_id, balance_usd,
ROW_NUMBER() OVER (PARTITION BY driver_id ORDER BY as_of_date DESC) AS rn
FROM fact_wallet_snapshot
WHERE as_of_date BETWEEN '2026-06-01' AND '2026-06-30'
)
SELECT ROUND(SUM(CASE WHEN rn = 1 THEN balance_usd ELSE 0 END), 2) AS correct_total
FROM ranked;
Putting both numbers in one query is what makes it a proof. SUM(balance_usd) over the same CTE gives the naive figure; SUM(CASE WHEN rn = 1 THEN balance_usd ELSE 0 END) gives the correct one. Same rows, same scan, two answers, and the interviewer can see which one the dashboard has been showing.
Common mistake: filtering the snapshot to a single hard-coded date (WHERE as_of_date = '2026-06-30') and calling it solved. That works until a driver has no row on the month-end date, and then they silently vanish from the total. Ranking per driver and keeping the latest row in the window survives a missing snapshot; an equality filter does not.
Interview nuance: "which measures on your fact table are semi-additive" is the standard follow-up to any star-schema answer. The strong version of the answer is three sentences: name the measure, name the dimension it will not sum across (almost always time), and say how you aggregate it instead (latest in the period, or an average-of-day-balances if finance asks for that). Then show the side-by-side if you have a keyboard.
On a real platform this differs. Bank and wallet warehouses run exactly this daily-balance snapshot, and it is usually the biggest table they own. Warehouse dialects add conveniences here: Snowflake and BigQuery let you reach for
LAST_VALUE(...) OVER (... ROWS BETWEEN UNBOUNDED PRECEDING AND UNBOUNDED FOLLOWING), and a semantic layer lets you declare the rule once instead of re-deriving it per query: dbt's Semantic Layer (MetricFlow) declares the measure withnon_additive_dimension: { name: metric_time, window_choice: max }, which is exactly "do not sum this across time, take the latest", so no analyst can sum it by accident.ROW_NUMBERis the portable version that works everywhere, including here.
CREATE TABLE fact_wallet_snapshot (
driver_id INTEGER,
as_of_date TEXT, -- the snapshot date; the last one in a month is the month-end balance
balance_usd REAL -- the wallet balance AS OF that date, not a change during it
);
INSERT INTO fact_wallet_snapshot (driver_id, as_of_date, balance_usd) VALUES
(42, '2026-06-10', 980.00), (42, '2026-06-20', 1105.75), (42, '2026-06-30', 1240.50),
(51, '2026-06-10', 1520.00), (51, '2026-06-20', 1810.30), (51, '2026-06-30', 1975.40),
(63, '2026-06-10', 890.15), (63, '2026-06-20', 805.60), (63, '2026-06-30', 720.80),
(74, '2026-06-10', 640.60), (74, '2026-06-20', 715.40), (74, '2026-06-30', 860.25),
(85, '2026-06-10', 300.55), (85, '2026-06-20', 375.20), (85, '2026-06-30', 430.10),
(96, '2026-06-10', 1240.00), (96, '2026-06-20', 1455.85), (96, '2026-06-30', 1610.95),
(42, '2026-07-10', 1310.20), (42, '2026-07-20', 1402.65), (42, '2026-07-31', 1520.00),
(51, '2026-07-10', 2050.75), (51, '2026-07-20', 1990.40), (51, '2026-07-31', 2115.60),
(63, '2026-07-10', 760.25), (63, '2026-07-20', 815.70), (63, '2026-07-31', 402.40),
(74, '2026-07-10', 905.35), (74, '2026-07-20', 980.10), (74, '2026-07-31', 1044.75),
-- driver 85 has NO 2026-07-31 row on purpose: an equality filter on the month-end date
-- drops them from July entirely, which is the failure the lesson warns about.
(85, '2026-07-10', 470.90), (85, '2026-07-20', 512.30),
(96, '2026-07-10', 1700.10), (96, '2026-07-20', 1655.25), (96, '2026-07-31', 1788.30);-- The wrong number and the right number for June, in one result set.
WITH ranked AS (
SELECT driver_id, balance_usd,
ROW_NUMBER() OVER (PARTITION BY driver_id ORDER BY as_of_date DESC) AS rn
FROM fact_wallet_snapshot
WHERE as_of_date BETWEEN '2026-06-01' AND '2026-06-30'
)
SELECT ROUND(SUM(balance_usd), 2) AS naive_total,
ROUND(SUM(CASE WHEN rn = 1 THEN balance_usd ELSE 0 END), 2) AS correct_total
FROM ranked;Apply
Your turn
The task this lesson builds to.
Write a query that returns, per home city for June 2026, both the wrong naive sum and the correct month-end total as (home_city, naive_total, correct_total), each rounded to 2 decimals, over fact_wallet_snapshot(driver_id, as_of_date, balance_usd) joined to dim_driver(driver_id, home_city).
naive_total sums every June snapshot row. correct_total keeps only each driver's latest June snapshot, then sums those. Restrict to as_of_date between '2026-06-01' and '2026-06-30', alias the columns exactly, and order by correct_total descending.
5 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 correct month-end balance per city for both month-ends as (home_city, month_end, correct_total) rounded to 2 decimals, over the same fact_wallet_snapshot and dim_driver tables.
Inside each calendar month, keep only each driver's latest snapshot, then sum those balances per city. month_end is the last snapshot date in that calendar month, which is not always the date of the row you kept: one driver has no snapshot on the July month-end and still belongs in the July total. Order by month_end then home_city.
5 hints and 1 automated check are waiting in the workspace.