Week 5 · Model it in SQL › Day 30
Day 30. Dimensions done right (keys, dates, SCD2)
By the end of today you can
- Build surrogate keys and "unknown" members
- Generate a date dimension with fiscal and business calendars
- Implement SCD Type 1, 2 and 3 and choose between them
- Join facts to the dimension version valid at the event time
1. Why this matters
"What was the Double Stack's price when this order happened?", "Which region was store R104 in last quarter, before the re-org?". Answering those correctly needs history in dimensions, the topic that most trips up developers in their first BI job.
2. Surrogate keys and unknown members
- Facts reference dimensions by a surrogate key (
menu_item_key int), not the natural code. - Every dimension has an unknown row (key
-1, name "Unknown") and optionally not applicable (-2). Facts whose code doesn't match yet point to-1instead of being dropped, and the DQ report counts them.
3. The date dimension
One row per date, generated (never loaded from a source), covering every date facts could have plus a few years ahead.
-- DuckDB / Postgres flavour
create table dim_date as
select d::date as date_key, year(d) as year, quarter(d) as quarter, month(d) as month,
strftime(d, '%b') as month_name, isodow(d) as iso_dow, strftime(d, '%a') as dow_name,
week(d) as iso_week, isodow(d) in (6, 7) as is_weekend,
-- fiscal year starting 1 May (set per company)
case when month(d) >= 5 then year(d) + 1 else year(d) end as fiscal_year,
(d - interval 364 day)::date as same_weekday_last_year -- 52 weeks back: same weekday
from generate_series(date '2024-01-01', date '2028-12-31', interval 1 day) t(d);
Add a holiday table (Ontario statutory holidays, long weekends, local events like festival weeks) and join it in. Hospitality and QSR performance is unreadable without it.
4. Slowly changing dimensions
| Type | Behaviour | Use for |
|---|---|---|
| SCD 0 | Never change | Original values (date opened) |
| SCD 1 | Overwrite | Corrections (typo in a name) |
| SCD 2 | New row per change with valid_from, valid_to, is_current |
Anything you report history by: price, region, brand assignment |
| SCD 3 | Extra "previous" column | One-off re-orgs where people want old vs new side by side |
-- point-in-time join: the menu item version valid when the order happened
select f.order_id, f.business_date, m.item_name, m.price as list_price_at_order
from fct_order_lines f
join dim_menu_item m
on m.menu_item_id = f.menu_item_id
and f.order_ts >= m.valid_from and f.order_ts < m.valid_to; -- half-open interval
Better: resolve the right menu_item_key at load time and store it on the fact. Then reports join on the key only, and they're simple and fast.
Maths and principle
Principle: SCD2 versions must tile time: no gaps, no overlaps, per natural key.
Equation: for versions \(v_1 \dots v_n\) of key \(k\), sorted by start: \(\text{to}(v_i) = \text{from}(v_{i+1})\) and \(\text{to}(v_n) = \infty\) (use 9999-12-31). Half-open intervals \([\text{from}, \text{to})\) guarantee each instant \(t\) belongs to exactly one version.
Analogy: a partition of the real line into intervals. Where it breaks: closed intervals \([a, b]\) double-match the boundary instant, and gaps orphan facts. Test both.
Try it: versions [Jan 1, Mar 1), [Mar 1, ∞). An order at Mar 1 00:00:00 matches which? (Only the second.)
The 5-year lens
Ask the business which attributes need history, attribute by attribute. Making everything SCD2 explodes row counts and confuses users ("why are there three Classic Burgers?"). Typical answer: price, region/district, brand/franchise status, manager: SCD2. Names and descriptions: SCD1. And keep is_current so a slicer can show only current names while facts keep historical keys.
Lab
- Generate
dim_date2024–2028 with fiscal year (May start), ISO week, same weekday last year, and an Ontario holidays table you type in for 2026–2027. - Simulate menu price changes: write a script that changes 6 menu items' prices on random dates and writes daily menu snapshots
reference/menu_snapshots/<date>.csv. - Build
dim_menu_itemas SCD2 from the snapshots in SQL: detect changes withlag()over the hash of tracked columns, computevalid_from/valid_to/is_current. - Tests: no overlaps, no gaps, exactly one current row per item, unknown member exists.
- Resolve
menu_item_keyonfct_order_linesat load time. Show revenue at list price vs actual price for an item whose price changed.
Ship
feat(model): date dimension, holidays, SCD2 menu dimension with tests.
Check yourself
1. A store moved from region "West" to "Central" on 1 July. Reports by region for last year should show it in...
SCD2 keeps the history, so facts keep the region valid at the time.
2. Why use half-open intervals [valid_from, valid_to) for SCD2?
Boundaries belong to exactly one side.
3. A fact row's menu code doesn't exist in the dimension yet. Best handling?
Revenue stays complete; the gap stays visible.