Dimension Patterns: Conformed, Role-Playing, Degenerate, Junk
Model conformed, role-playing, degenerate, and junk dimensions, plus factless facts, the way data-engineering interviewers ask about them.
Why conformed dimensions hold a warehouse together
An enterprise warehouse is many fact tables at once: fact_orders, fact_returns, fact_web_sessions. If each one invents its own idea of "customer" or "date", no two marts slice the same way and cross-mart numbers never reconcile. A conformed dimension fixes this: it is one shared table, like a single dim_date or dim_customer, that every fact joins to. Because they all reference the same rows, "revenue by month" from orders and "returns by month" from returns line up exactly. Kimball calls the grid of which facts share which conformed dimensions the bus matrix, and it is the backbone of a coherent warehouse.
Four more patterns show up on that same schema, and interviewers name them directly.
Four patterns on one schema
Role-playing dimension. One physical dimension joined more than once under different meanings. An order has an order date and a ship date. You do not build two date tables. You join the single dim_date twice, once for the order role and once for the ship role, and each alias contributes its own columns.
Degenerate dimension. A dimension attribute with no dimension table. An order_number or invoice_number is a genuine attribute of a sale, but it has nothing to describe (no name, no category), so you store it directly on the fact and skip the dim table. The dimension has degenerated to a bare key sitting on the fact.
Junk dimension. Several low-cardinality flags, like is_gift, is_expedited, and is_first_order, folded into one small table of the distinct combinations that actually occur. The fact then carries a single flag_key instead of three separate boolean columns or three tiny foreign keys.
Factless fact table. A fact with no measures. It records that an event or relationship happened, like "student attended class" or "promotion covered product". There is nothing to sum, so you answer questions with COUNT(*) over the rows.
Worked example: role-playing dim_date
SELECT
f.order_key,
order_d.month AS order_month,
ship_d.month AS ship_month
FROM fact_orders f
JOIN dim_date order_d ON order_d.date_key = f.order_date_key
JOIN dim_date ship_d ON ship_d.date_key = f.ship_date_key;
Order 500 was placed in month 1 and shipped in month 2. The same dim_date supplies both months; the aliases order_d and ship_d are what give the two roles distinct meaning.
Pitfalls
- Duplicating a shared dimension. Building a separate
dim_ship_datenext todim_order_dateforks one conformed dimension into two. Now a fiscal-calendar fix has to be applied twice and the copies drift apart. Join the singledim_datetwice instead. - A tiny dimension per flag. Giving
is_gift,is_expedited, andis_first_ordereach its own dimension table (dimension explosion) bloats the schema with three one-column joins. Fold them into one junk dimension of the combinations that occur.
Interview nuance: conformed dimensions are what let two independently built marts be compared at all, so when asked "how do you make revenue and returns slice consistently by month", the answer is a single conformed dim_date that both facts join, not two lookalike date tables.
CREATE TABLE dim_date (
date_key INTEGER PRIMARY KEY,
full_date TEXT,
year INTEGER,
month INTEGER
);
INSERT INTO dim_date (date_key, full_date, year, month) VALUES
(1, '2026-01-05', 2026, 1),
(2, '2026-02-11', 2026, 2),
(3, '2026-03-20', 2026, 3);
CREATE TABLE fact_orders (
order_key INTEGER PRIMARY KEY,
order_date_key INTEGER,
ship_date_key INTEGER,
revenue INTEGER
);
INSERT INTO fact_orders (order_key, order_date_key, ship_date_key, revenue) VALUES
(500, 1, 2, 250),
(501, 1, 3, 400),
(502, 2, 2, 150);SELECT
f.order_key,
order_d.month AS order_month, -- dim_date in its "order date" role
ship_d.month AS ship_month, -- SAME table, joined again in its "ship date" role
f.revenue
FROM fact_orders f
JOIN dim_date order_d ON order_d.date_key = f.order_date_key
JOIN dim_date ship_d ON ship_d.date_key = f.ship_date_key
ORDER BY f.order_key;Apply
Your turn
The task this lesson builds to.
Write a load that fills order_month_report(order_key, order_month, ship_month) with one row per order, where order_month is the calendar month the order was placed and ship_month is the month it shipped. fact_orders(order_key, order_date_key, ship_date_key, revenue) carries two date keys, and both reference the single conformed dim_date(date_key, full_date, year, month).
Fetch both months by joining dim_date twice under different aliases (an order role and a ship role), because one physical date table plays both roles. Lead with DELETE FROM order_month_report; so the load survives a re-run.
Expected rows: order 101 (placed in month 1, shipped in month 2) yields order_month = 1, ship_month = 2; order 102 yields 1, 1; order 103 yields 2, 3; order 104 yields 2, 2.
4 hints and 4 automated checks are waiting in the workspace.
Practice
Make it stick
A second problem on the same idea, so it survives past today.
Write a load that builds the junk dimension dim_order_flags(flag_key, is_gift, is_expedited, is_first_order) from fact_order, then backfills fact_order.flag_key to point at it. dim_order_flags must hold exactly one row per distinct combination of the three flags that occurs in fact_order, with flag_key auto-assigned. Then set each fact_order.flag_key to that order's matching combination.
The six seeded orders use four distinct flag combinations, so dim_order_flags ends with 4 rows, every combination appears exactly once, and every fact_order.flag_key resolves to one of them (zero orphans). Lead with DELETE FROM dim_order_flags; and UPDATE fact_order SET flag_key = NULL; so the load re-runs cleanly.
4 hints and 4 automated checks are waiting in the workspace.