learn-data-modeling-with-phoebe / Builder session 6 of 10
Learn Data Modeling with Phoebe · Builder track · Session 6 of 10

Dimension design

Session b5 proved the source schema cannot answer a business question cheaply. This session builds the half of the fix that answers "by what?" - six dimensions, governed by four rules, each rule preventing a specific failure you have probably already lived through. This is the session that ships sql/10_dim_ddl.sql, and the four numbered rules at the top of that file are the spine of the next 45 minutes.

🟠 Builder track · hands-on Analysts · analytics engineers · DE · DS Ships sql/10 · real SQLite 45 min
0-3 · Welcome 3-16 · The four rules, and what each prevents 16-40 · Build-along: read the DDL, apply a type-2 change 40-45 · Q&A
Part 0

Dimensions answer "by what?"

A star schema is two kinds of table doing two jobs. Facts answer how much: revenue, units, attempts, carts. Dimensions answer sliced how: by merchant, by product, by shopper, by date, by hour, by payment method. The test is embarrassingly simple and it holds up: if a business question contains the word "by", the word after it is a dimension. b5's worksheet already collected them, so today you build them and b7 builds the facts that hang off them.

Bazaar gets six dimensions. Four rules govern all six, and none of them is a style preference - each one exists because a specific, expensive failure happens without it. History that rewrites itself. A join count that grows with every question. A dashboard that reports today's commission rate against April's sales. And a quarter of revenue reporting that is quietly 4% short because one attribute was NULL.

Live - presented in session Self-study - read after class ▶ Live SQL - editable & runnable Sources covered
★ What you walk out with today sql/10_dim_ddl.sql, shipped and running: six dimensions with surrogate keys, flat-and-wide attributes, type-2 history where it matters, and not one NULL. Plus the reflex to ask, of any dimension you are handed, "what happens to old facts when this attribute changes?"
Part 1 · covers Kimball on surrogate keys and snowflaking

Rule 1 and rule 2: keys, then flatness 7 min live

Here is the shape. One fact in the middle, six dimensions around it, one join from the fact to each. Nothing joins a dimension to another dimension, and that absence is a design decision rather than an omission.

dim_date 215 rows · generated dim_time_of_day 24 rows · generated dim_user 61 rows · SCD type 1 fact_order_item one product line on one order 1,434 rows · 5 surrogate keys dim_merchant 13 rows · SCD type 2 dim_product 61 rows · SCD type 2 dim_payment_method 7 rows · SCD type 1 Six dimensions, one fact, one join each. Every line is a surrogate key on the fact pointing at a dimension's *_sk. No line runs dimension to dimension - that would be a snowflake, and it is exactly what rule 2 chooses against. b7 designs the two other facts that share these dimensions.
🔍 Click to zoom - Bazaar's star. dim_payment_method is lighter because fact_order_item does not use it; fact_transaction does.
LiveRule 1 · A surrogate key on every dimension, with the business key riding along4 min

Every dimension gets an integer key that means nothing: merchant_sk, product_sk, user_sk. The real business identifier stays on the row as an ordinary column you can still read and still join on: merchant_id = 'M07', product_id = 1001. Two keys, two jobs.

The failure it prevents: history that cannot change without breaking old facts. If fact_order_item stored merchant_id = 'M07' directly, then there is exactly one row in the world describing M07, and the moment its tier or commission changes you have to choose between overwriting the past or refusing the change. With a surrogate key, M07 can have as many versions as it needs and every fact keeps pointing at the version that was true when it happened. Rule 3 is only possible because rule 1 came first.

  • Meaningless on purpose. A surrogate key must carry no information - no embedded date, no source-system prefix, no check digit. The instant it means something, somebody parses it, and now you cannot change it.
  • Narrow and integer. Fact tables are the big tables, and every dimension key is a column on every fact row. An integer beats a 36-character UUID on scan cost, join cost and storage, at hundreds of millions of rows.
  • Keep the business key visible. A dimension whose natural key is hidden is unusable for reconciliation. When finance asks why M07's number differs from their ledger, you need to be able to say WHERE merchant_id = 'M07' out loud.
  • The business key is not unique on a type-2 dimension. dim_merchant.merchant_id deliberately has no UNIQUE constraint, because versions are rows. Only (merchant_id, is_current = 1) is unique, which is why sql/10 indexes exactly that pair.
LiveRule 2 · Flat and wide, not snowflaked - and yes, this contradicts b24 min

dim_product carries merchant_name and the merchant's category as columns, even though dim_merchant already has both. In session b2 that duplication was a textbook third-normal-form violation and the lab scored it as a bug. Here it is the product. The reversal is deliberate, and knowing why it reverses is the difference between following a rule and understanding one.

  • In OLTP, duplication is a bug because the table is written to constantly and two copies will eventually disagree. The update anomaly is real and it costs money.
  • In a dimension, duplication is safe because the table is written by exactly one process, on a schedule, from one source of truth. There is no second writer to disagree with. The anomaly needs two writers, and a load job is one.
  • And the duplication buys something concrete: it removes a join from every single analyst query, and from every query an AI agent generates. "Revenue by merchant name" is one join instead of two. Snowflaking - normalizing the dimension back out into dim_product plus dim_merchant plus dim_category - would add a hop per attribute, and every hop is a chance to pick the wrong key or drop rows.
  • Where snowflaking is defensible: a genuinely enormous dimension with a huge, rarely-used attribute group hanging off it, or an attribute set governed by a different team on a different cadence. Both are real, both are rare, and both should be argued rather than assumed.
The reconciling principle Normalization protects writers. Denormalization serves readers. b2 and b6 are not in conflict because they are optimising for different people: the source schema has thousands of concurrent writers, and the star has one writer and every reader in the company.
Self-studyThe two dimensions you generate rather than source3 min read

dim_date is the one dimension you always generate and never source. No application owns the calendar, so no application can hand it to you. Generate it, cover a range wider than your data (Bazaar's data runs 2026-04-01 to 2026-06-29; the dimension runs 2026-03-01 to 2026-09-30, 215 rows including an unknown member), and put every calendar attribute anyone will ever group by into columns: year, quarter, month, month name, day of month, day of week, day name, week of year, and a weekend flag. That last column is why two dashboards can no longer disagree about whether Saturday is a weekend day.

date_key is a readable integer, 20260610 rather than a meaningless sequence, which is the one sanctioned exception to "surrogate keys mean nothing". It earns the exception because it makes a fact table scannable by eye and partitionable by prefix, and because the calendar is the one thing that genuinely never gets restated.

dim_time_of_day is kept separate from dim_date, and that is the whole point. Folding hours into the date dimension would multiply it by 24 for no benefit. Keeping them apart means "when is our peak hour?" groups across all 90 days at once, on 24 rows, instead of needing an expression over 2,160 date-hour combinations. Bazaar's answer, which b8 chases properly: 21:00 leads with 96 attempts, then 19:00 with 94, then 13:00 and 20:00 tied at 89.

Part 2 · covers Kimball SCD types 1 and 2

Rule 3 and rule 4: history, and never NULL 6 min live

Two dimensions carry attributes whose history somebody will eventually ask about, and four do not. Getting that split right is the highest-value decision in this session, because type 2 is not free and applying it everywhere is as wrong as applying it nowhere.

dim_merchant, SCD type 2: one merchant, two rows, two validity windows merchant_sk merchant_id tier commission_pct valid_from valid_to is_current 4 M05 bronze 0.18 2025-09-18 2026-06-15 0 12 M05 gold 0.13 2026-06-16 9999-12-31 1 Where a fact lands Sales before 2026-06-16 keep merchant_sk 4: bronze, 0.18 After: merchant_sk 12, gold, 0.13 2025-09-18 2026-06-15 9999-12-31 dim_user, SCD type 1, for comparison One row per shopper, overwritten in place. Change a city and last month's report changes with it. That is the right trade here: nobody has ever asked for the history of a shopper's city. Type 2 is not free: the dimension grows a row per change, and every query must either say is_current = 1 or supply a date. Use it only where somebody would notice the difference.
🔍 Click to zoom - the row pair, the two windows, and the type-1 alternative side by side
DimensionOne row isRowsTypeWhat it is for
dim_dateone calendar day215generated, never sourcedevery "by month / weekday / quarter / weekend" question, defined once
dim_time_of_dayone hour of the day24generated, never sourcedpeak-hour questions that group across all 90 days at once
dim_userone shopper61SCD type 1 (overwrite)slicing by country, city, acquisition channel and tenure band
dim_merchantone merchant, per version13SCD type 2 (tier, commission_pct)revenue and commission by the tier and rate in force at time of sale
dim_productone product, per version61SCD type 2 (list_price, status)slicing by category, price band and status, with the merchant flattened in
dim_payment_methodone payment method7SCD type 1 (overwrite)saying "cards versus wallets versus bank" in one GROUP BY

The last four counts each include one unknown member at key -1, and dim_date's 215 includes one too. That is rule 4, and it is the reason every fact in this warehouse has a 0.0% unknown-member rate rather than a silent hole.

LiveRule 3 · Type 2 on the two dimensions whose history matters3 min

Two dimensions get slowly-changing type 2 treatment, and the choice is driven entirely by whether a real person would ever notice the difference.

  • dim_merchant: tier and commission_pct. Bazaar's take rate is a contractual number that changes. A sale in April must report April's tier and April's commission, not today's. Get this wrong and the marketplace restates its own historic take every time it renegotiates a contract - which is a finance conversation you cannot win.
  • dim_product: list_price and status. A product's list price moves and its status flips between active and out of stock. Session b8 explains half a revenue drop using product status at the time, which is only possible if that status was versioned rather than overwritten.
  • dim_user: type 1, on purpose. Nobody has ever asked for the history of a shopper's city. Type 2 here would double the row count for a question no one asks, and slow down every join for nothing.

The mechanics are three columns. valid_from and valid_to bound the version, is_current flags the live one, and the current row uses valid_to = '9999-12-31' rather than NULL so that a date-range predicate never has to special-case it. A fact stores the *_sk of the version that was true when the event happened, so the fact never needs updating again.

The scope line, stated plainly This session designs the type-2 structure and loads it once, with one current version per merchant and product. Applying a type-2 change on every run - detecting the change, closing the old row, inserting the new one, handling late-arriving facts - is a loading problem, and it belongs to learn-data-warehouse-with-phoebe (builder sessions 4 to 6). The build-along below applies one change by hand so you can see what the loader will automate, and that is as far as this course goes.
LiveRule 4 · No NULLs in dimension attributes, ever3 min

Every attribute column in every Bazaar dimension is NOT NULL DEFAULT 'unknown', and every dimension has a member at key -1 whose attributes are all the string 'unknown'. Two devices, one purpose.

  • A NULL attribute silently drops rows from every inner join and every predicate. WHERE city <> 'Singapore' excludes the NULL cities. So does WHERE city = 'Singapore'. So does a GROUP BY that a BI tool wrote for you. The row does not error, it does not warn, it just is not there. The string 'unknown' is a value, so it groups, filters and totals like any other.
  • A missing key does the same thing, worse. If a fact's merchant_sk is NULL and the query inner-joins to the dimension, the whole fact row vanishes - money included. Pointing it at -1 instead keeps the row, keeps the revenue in the total, and puts it in a bucket literally labelled "unknown" so somebody notices.
  • That is why the load uses LEFT JOIN plus COALESCE to -1 for every dimension lookup, never an inner join. Silently-missing revenue is the worst failure mode in this whole course, and an inner join in a fact load is how you get it.

The arithmetic of the failure is what makes it dangerous. Four percent of rows missing is not a number anyone spots in a trend chart, and it is a big enough number to change a decision. The demo below reproduces the whole thing in twelve rows of SQL, and it goes from 400 to 300 to 200 and back to 400.

Self-studyModeled attributes: where a dimension earns its keep3 min read

Copying source columns into a dimension is transport, not modeling. The columns that make a dimension worth building are the ones that do not exist anywhere in the source:

  • price_tier on dim_product: budget / mid / premium. The source has a number. The business thinks in bands. Defining the band once in the dimension is what stops three dashboards from disagreeing about what "premium" means.
  • tenure_bucket on dim_user: new / 3-12m / 1y+. The source has a signup timestamp. Every analyst who bands it writes a slightly different CASE expression and gets a slightly different answer.
  • daypart on dim_time_of_day: overnight / morning / lunch / afternoon / evening / late. Derived from nothing at all - it is pure business vocabulary, and it is the word a person says out loud instead of "hours 11 through 13".
  • method_family on dim_payment_method: card / wallet / bank. Grouping by the raw source string card_visa is not the grouping anyone wants, and the alternative is a CASE statement that differs per analyst.

All four share one property: they turn a decision that used to be made in every query into a decision made once, in one place, reviewable in a pull request. That is the actual product of dimension design. The columns are cheap; the agreement is the asset.

Demo 1 of 2

Build-along: read what you just shipped ★ 14 min · everyone builds

Every editor below has the source tables, all six dimensions, all three facts, the four marts and the agent views loaded - the same objects sql/10_dim_ddl.sql and sql/12_oltp_to_star.sql create when you run them on your laptop. Read the DDL alongside these queries; the SQL and the file are the same lesson twice.

Count the six. Confirm the dimension inventory and its types match the table above, then check the numbers against sql/10.

Look at rule 1 in the data. Surrogate key, business key, validity window and is_current, all on one row, including the -1 member.

Count the joins rule 2 removed. Two source tables to get a merchant name onto a product, versus zero.

See where the modeled columns come from. Four attributes with no source column, and the source thing each was derived from.

SELECT 'dim_date' AS dimension, 'one calendar day' AS one_row_is,
       'generated'  AS scd_type, COUNT(*) AS row_count FROM dim_date
UNION ALL SELECT 'dim_time_of_day',    'one hour of the day',       'generated', COUNT(*) FROM dim_time_of_day
UNION ALL SELECT 'dim_user',           'one shopper',               'type 1',    COUNT(*) FROM dim_user
UNION ALL SELECT 'dim_merchant',       'one merchant per version',  'type 2',    COUNT(*) FROM dim_merchant
UNION ALL SELECT 'dim_product',        'one product per version',   'type 2',    COUNT(*) FROM dim_product
UNION ALL SELECT 'dim_payment_method', 'one payment method',        'type 1',    COUNT(*) FROM dim_payment_method;
SELECT merchant_sk, merchant_id, merchant_name, tier, commission_pct,
       valid_from, valid_to, is_current
FROM dim_merchant
ORDER BY merchant_sk;
-- snowflaked path: the merchant's name is one table away from the product
SELECT p.product_id, p.product_name, m.merchant_name, m.category AS merchant_category
FROM products p
JOIN merchants m ON m.merchant_id = p.merchant_id
ORDER BY p.product_id
LIMIT 5;

-- flat and wide: the same columns, zero joins, plus a modeled price_tier
SELECT product_id, product_name, category, merchant_id, merchant_name,
       list_price, price_tier, status, is_current
FROM dim_product
WHERE product_sk >= 0
ORDER BY product_sk
LIMIT 5;
SELECT 'price_tier'    AS modeled_attribute, 'dim_product'        AS lives_in,
       'list_price, a number'          AS derived_from,
       COUNT(DISTINCT price_tier)      AS distinct_values FROM dim_product
UNION ALL SELECT 'tenure_bucket', 'dim_user',           'signup_ts, a timestamp',
       COUNT(DISTINCT tenure_bucket) FROM dim_user
UNION ALL SELECT 'daypart',       'dim_time_of_day',    'nothing - pure business vocabulary',
       COUNT(DISTINCT daypart)       FROM dim_time_of_day
UNION ALL SELECT 'method_family', 'dim_payment_method', 'payment_method, a raw string',
       COUNT(DISTINCT method_family) FROM dim_payment_method;
Real world

How a dashboard loses 4% of revenue for a quarter. A retailer added a new fulfilment partner. The partner's code was not in the dimension yet, so the fact load's inner join dropped every line it touched: about 4% of orders, invisibly. The daily revenue chart dipped by less than its own noise band, nobody queried it, and the gap was found eleven weeks later during an audit - by which time the weekly business review had made three decisions on the short number. Two one-line changes would have prevented it: a LEFT JOIN with COALESCE to -1 in the load, and a validation check that fails the build when the unknown-member rate rises above zero. Bazaar has both, which is why validate_model.py runs 27 checks and reports 0.0% unknown on every fact.

Demo 2 of 2

Your turn: change history, then break a join ★ 10 min · build your own

Two experiments you should run yourself, because both are things people describe in words and then get wrong in code. Each editor starts from a fresh copy of the warehouse, so nothing you do here survives into the next box.

LiveQ1 · Promote a merchant, and prove April did not move5 min

Tiny Fern (M05) renegotiates: bronze at 18% becomes gold at 13%, effective 2026-06-16. Apply it the type-2 way - close the old version, open a new one - then check what happens to the sales that already happened.

-- close the current version at the last day it was true
UPDATE dim_merchant
   SET valid_to = '2026-06-15', is_current = 0
 WHERE merchant_id = 'M05' AND is_current = 1;

-- open the new version; it gets a brand new surrogate key
INSERT INTO dim_merchant (merchant_id, merchant_name, category, country, tier,
                          commission_pct, joined_date, valid_from, valid_to, is_current)
SELECT merchant_id, merchant_name, category, country, 'gold', 0.13, joined_date,
       '2026-06-16', '9999-12-31', 1
FROM dim_merchant
WHERE merchant_id = 'M05' AND is_current = 0;

SELECT merchant_sk, merchant_id, merchant_name, tier, commission_pct,
       valid_from, valid_to, is_current
FROM dim_merchant
WHERE merchant_id = 'M05'
ORDER BY valid_from;

-- and the sales that already happened: untouched, still bronze, still 18%
SELECT m.tier         AS tier_on_the_fact,
       m.commission_pct AS rate_used,
       COUNT(*)       AS lines,
       ROUND(SUM(f.net_amount), 2)       AS net_revenue,
       ROUND(SUM(f.commission_amount), 2) AS commission
FROM fact_order_item f
JOIN dim_merchant m ON m.merchant_sk = f.merchant_sk
WHERE m.merchant_id = 'M05'
GROUP BY m.tier, m.commission_pct;

That second result is the payoff. No fact row was updated, no historic commission was restated, and "revenue by merchant tier" for April still says bronze. Try the same thing with a type-1 dimension - just UPDATE dim_merchant SET tier = 'gold' - and every April sale silently becomes a gold-tier sale, at a commission rate that did not exist in April.

LiveQ2 · Watch a NULL eat revenue, then fix it with -15 min

Four fact rows, 400 dollars, one dimension with a NULL in it. Watch the total fall twice and then come back.

CREATE TABLE dim_channel_bad  (channel_sk INTEGER PRIMARY KEY, channel TEXT);
INSERT INTO dim_channel_bad  VALUES (1, 'paid_search'), (2, 'organic'), (3, NULL);

CREATE TABLE dim_channel_good (channel_sk INTEGER PRIMARY KEY, channel TEXT NOT NULL);
INSERT INTO dim_channel_good VALUES (-1, 'unknown'), (1, 'paid_search'),
                                    (2, 'organic'), (3, 'unknown');

CREATE TABLE fact_tiny (order_id INTEGER, channel_sk INTEGER, net_amount REAL);
INSERT INTO fact_tiny VALUES (1, 1, 100), (2, 2, 100), (3, 3, 100), (4, -1, 100);

SELECT 'the fact table itself' AS measured, COUNT(*) AS rows_counted,
       SUM(net_amount) AS revenue FROM fact_tiny
UNION ALL
SELECT 'inner join to the NULL dimension', COUNT(*), SUM(f.net_amount)
  FROM fact_tiny f JOIN dim_channel_bad d ON d.channel_sk = f.channel_sk
UNION ALL
SELECT 'and now filter by channel', COUNT(*), SUM(f.net_amount)
  FROM fact_tiny f JOIN dim_channel_bad d ON d.channel_sk = f.channel_sk
 WHERE d.channel <> 'nothing at all'
UNION ALL
SELECT 'inner join to the unknown-member dimension', COUNT(*), SUM(f.net_amount)
  FROM fact_tiny f JOIN dim_channel_good d ON d.channel_sk = f.channel_sk;

-- the same check, on the real thing
SELECT 'fact_order_item' AS fact_table, COUNT(*) AS fact_rows,
       SUM(CASE WHEN date_key = -1 OR user_sk = -1 OR merchant_sk = -1
                  OR product_sk = -1 THEN 1 ELSE 0 END) AS rows_on_unknown,
       ROUND(SUM(CASE WHEN date_key = -1 OR user_sk = -1 OR merchant_sk = -1
                        OR product_sk = -1 THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1) AS pct_unknown
FROM fact_order_item
UNION ALL
SELECT 'fact_transaction', COUNT(*),
       SUM(CASE WHEN date_key = -1 OR user_sk = -1 OR payment_method_key = -1 THEN 1 ELSE 0 END),
       ROUND(SUM(CASE WHEN date_key = -1 OR user_sk = -1 OR payment_method_key = -1
                      THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1)
FROM fact_transaction
UNION ALL
SELECT 'fact_cart', COUNT(*),
       SUM(CASE WHEN date_key = -1 OR user_sk = -1 OR merchant_sk = -1 THEN 1 ELSE 0 END),
       ROUND(SUM(CASE WHEN date_key = -1 OR user_sk = -1 OR merchant_sk = -1
                      THEN 1 ELSE 0 END) * 100.0 / COUNT(*), 1)
FROM fact_cart;

The third row is the cruel one: a perfectly ordinary filter, which has nothing to say about NULLs, quietly halves the total. A predicate on a NULL evaluates to NULL, and NULL is not true, so the row is gone. Replace the NULL with the string 'unknown' and every one of those queries returns 400.

SELECT t.hour_label, t.daypart, t.is_business_hour,
       COUNT(*)           AS attempts,
       SUM(f.is_approved) AS approvals,
       SUM(f.is_declined) AS declines
FROM fact_transaction f
JOIN dim_time_of_day t ON t.time_key = f.time_key
GROUP BY t.hour_label, t.daypart, t.is_business_hour
ORDER BY attempts DESC
LIMIT 6;
Self-studyQ3 · Audit one dimension you already own4 min

No SQL. Pick the dimension table your organisation uses most - customer, product, store, account - and put the four rules to it in order.

  • Rule 1: is the key a surrogate, or is the business identifier doing double duty? If a report ever needs to restate history, that answer decides whether you can.
  • Rule 2: how many joins does your most-asked question need? Every join beyond fact-to-dimension is an attribute somebody chose not to flatten.
  • Rule 3: name the attributes on it that change. For each one, ask whether a person would notice if last quarter's report changed when it changed. Every yes is a type-2 candidate; every no should stay type 1.
  • Rule 4: run a NULL count on every attribute column, and check whether an unknown member exists at all. Then check whether your fact loads use inner joins to this dimension. That combination is the 4% story in the callout above.

Bring the audit to b7. The facts you design there will point at exactly these dimensions, and a fact table can only be as trustworthy as the dimensions it joins to.

Homework

Try it yourself - this week ◐ 20-30 min total

Source material

Sources covered

Full source map, including what is deliberately out of scope, in materials/official-course-map.md. This page covers:

Kimball & Ross, The Data Warehouse Toolkit - surrogate keys, SCD types 1 and 2, snowflaking, the date dimensionParts 1-2 + both demos · all four rules, each with the failure it prevents
Kimball on conformed dimensions - one key, one set of values, shared across factsPart 1 · the six dimensions b7's three facts will all point at
Codd / relational fundamentals - normalization as a write-side optimisationRule 2 · why flat and wide reverses b2's discipline rather than contradicting it
Inmon, Building the Data Warehouse - normalized core versus dimensional martRule 2 · snowflaking as the point where the two schools meet; argued in b5
dbt layering conventions - dimensions as marts-layer models with tested contractsNamed; the not-null and accepted-values tests behind rule 4 are b8. Incremental and type-2 LOAD mechanics are learn-data-warehouse-with-phoebe, not this course
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · Why does every Bazaar dimension carry a meaningless integer surrogate key when the business key is right there?

The narrow-integer join is a real but secondary benefit. The reason is rule 3: with a surrogate key, dim_merchant.merchant_id is deliberately not unique, M05 can have two rows with two validity windows, and no fact ever needs updating when the business changes its mind.

2 · dim_product stores merchant_name even though dim_merchant has it. Session b2 called that a 3NF violation. What changed?

Normalization protects writers; denormalization serves readers. The source schema has thousands of concurrent writers and must be normalized. The dimension has one writer on a schedule, so flattening costs nothing and buys a join back on every question forever. Table size has nothing to do with it.

3 · Why does an unknown attribute become the string 'unknown' rather than NULL?

A predicate on NULL evaluates to NULL, which is not true, so the row is excluded by both = 'x' and <> 'x'. The string 'unknown' is an ordinary value that groups, filters and totals normally - and the matching -1 member keeps fact rows alive when a key lookup misses. That pair is why every Bazaar fact reports a 0.0% unknown-member rate rather than a silent hole.

Builder session 6 cheat sheet · pin this

Rule 1 · surrogate keysMeaningless integer *_sk on every dimension, business key riding along as a readable column. History changes by adding a row.
Rule 2 · flat and widedim_product carries merchant_name. One writer means duplication is safe, and it deletes a join from every query forever.
Rule 3 · type 2 where it mattersdim_merchant (tier, commission_pct) and dim_product (list_price, status). valid_from / valid_to / is_current.
Rule 4 · never NULL'unknown' for attributes, -1 for keys, LEFT JOIN plus COALESCE in the load. NULL drops rows with no error.
Type 1 on purposedim_user and dim_payment_method overwrite. Nobody has ever asked for a shopper's city history.
dim_dateAlways generated, never sourced. 215 rows, wider than the data. date_key 20260610 is the sanctioned meaningful key.
dim_time_of_daySeparate from dim_date, 24 rows, so peak-hour questions group across all 90 days at once.
Modeled attributesprice_tier, tenure_bucket, daypart, method_family. No source column. One agreement instead of ten CASE statements.