Where the build stands
The staging layer is live: stg_customers, stg_products, stg_orders, stg_order_items, stg_subscriptions - typed, gated, replayable. But answering "revenue by month by city" still takes a three-table join through OLTP-shaped tables, and every analyst writes that join slightly differently. Tonight you reshape staging into the core layer: one fact table ringed by dimensions. The shape is called a star, and it is the default answer-machine of the entire industry.
Grain first, then facts, then dimensions 8 min live
Before any SQL, a dimensional modeler says one sentence out loud: "One row of this fact table is one order line." That sentence is the grain, and every later decision - what to measure, what to join, what to sum - either honors it or breaks the table. Facts are events with numbers (an order line happened: quantity 2, price 16.00). Dimensions are nouns with context (who bought, what they bought, when).
LiveSay the grain before you write any SQL4 min▶
Why one row = one order line and not one order? Because order 1001 contains two products, and if a row were a whole order you could not ask "revenue by product" without exploding the table. Grain is chosen at the finest level the business will ever ask about - you can always roll up, you can never drill below the grain.
- Fact table: one row per event at the declared grain, holding measures (numeric, summable: quantity, line_revenue) plus foreign keys to each dimension.
- Dimension table: one row per noun (per customer, per product, per calendar day), holding descriptive attributes you filter and group by.
- The test: "Can I SUM it meaningfully?" - measure, goes on the fact. "Do I say GROUP BY or WHERE with it?" - attribute, goes on a dimension.
Prove today's grain to yourself - the fact will have more rows than there are orders, because orders fan out into lines:
SELECT (SELECT count(*) FROM stg_orders) AS orders,
(SELECT count(*) FROM stg_order_items) AS order_lines;
Self-studyGrain disasters, so you recognize them early3 min read▶
- Mixed grain: someone appends order-level shipping fees into the line-level fact. SUM(line_revenue) is now wrong in a way nobody notices for a quarter.
- Grain too coarse: the fact stores one row per order with a "top product" column. Six months later the founder asks for revenue by product, and the answer is a rebuild.
- Undeclared grain: the table has no stated grain at all, so every analyst guesses. Two dashboards, two numbers, one awkward meeting. The fix costs one sentence, said early.
Inmon vs Kimball: two roads to the core 6 min live
The SQL course taught you to normalize - split data into small tables so every fact lives in exactly one place. Analytics deliberately walks that road backwards: we denormalize, copying context onto wide dimensions so questions need fewer joins. How much to denormalize, and when, is the field's oldest argument - and it has two named schools.
LiveInmon in one card2 min▶
Bill Inmon, "father of the data warehouse": build a normalized (3NF) enterprise core first - every subject area, every relationship, modeled once for the whole company - then carve team-facing dimensional marts off it. Top-down. You get one integrated truth and superb consistency; you pay in time-to-first-answer and in needing enterprise-wide agreement before shipping. Big regulated organizations with long horizons still choose this road.
LiveKimball in one card2 min▶
Ralph Kimball: skip the enterprise 3NF core - go dimensional from day one. Build one star per business process (orders tonight, subscriptions in b7), and make them agree by sharing conformed dimensions - the same dim_customer joined by every fact, a "bus" of shared nouns. Bottom-up. You ship answers in weeks; the discipline you owe in return is keeping those dimensions conformed, which is exactly what b4 teaches.
Mapping Daybreak's columns to their star homes 5 min live
Modeling is deciding, column by column: is this a measure (goes on the fact), an attribute (goes on a dimension), or a key (glue)? Here is tonight's full mapping - read it once now, then watch Demo 1 make it real.
| Source column | Star home | Why |
|---|---|---|
order_items.quantity, unit_price | measures on fct_order_line | numeric, summable, at the grain |
stg_order_items.line_amount | measure line_revenue on the fact | computed once in staging, renamed for the business |
customers.name, city, country, plan | attributes on dim_customer | context about WHO - you group by these, never sum them |
products.name, category, roast, price | attributes on dim_product | context about WHAT |
orders.order_date | date_key on the fact, joining dim_date | WHEN - the calendar becomes its own dimension (b4) |
orders.status, channel | attributes ON the fact | low-cardinality event context - not worth a dimension yet |
orders.order_id | degenerate dimension on the fact | an ID with no attributes of its own - rides along for tracing |
Status on the fact - a judgment call, made visibly. Purists would build dim_order_status. With three values and no attributes, that dimension would be ceremony. Kimball's own guidance blesses keeping small event context on the fact. The skill is not following a rule - it is writing the decision down where the next analyst can see it.
Build the star, live ★ 12 min · everyone builds
Four moves: two dimensions, one fact, one proof. The playground starts with b2's staging layer already in place (data-setup="staging"), so this script is exactly the core-layer build that would run after staging in production.
Move 1 - dim_customer: copy the staged customers and stamp each row with a row_number() surrogate key. Why not reuse customer_id? Hold that thought - it is b4's opening argument.
Move 2 - dim_product: same pattern, product side. Every dimension build in this track will look like these two.
Move 3 - fct_order_line: join orders to their lines, swap natural IDs for the new surrogate keys, keep the measures. Note the grain holds: one row out per order line in.
Move 4 - prove it: the final SELECT counts each table. Check dim_customer = 15, dim_product = 8, and the fact matches your Part 1 line count.
CREATE TABLE dim_customer AS
SELECT row_number() OVER (ORDER BY customer_id) AS customer_key,
customer_id, name, city, country, plan, signup_date
FROM stg_customers;
CREATE TABLE dim_product AS
SELECT row_number() OVER (ORDER BY product_id) AS product_key,
product_id, name, category, price, roast
FROM stg_products;
CREATE TABLE fct_order_line AS
SELECT dc.customer_key,
dp.product_key,
o.order_date AS date_key,
o.order_id,
oi.quantity,
oi.unit_price,
oi.line_amount AS line_revenue,
o.status,
o.channel
FROM stg_orders o
JOIN stg_order_items oi USING (order_id)
JOIN dim_customer dc ON o.customer_id = dc.customer_id
JOIN dim_product dp ON oi.product_id = dp.product_id;
SELECT 'dim_customer' AS star_table,
count(*) AS row_count FROM dim_customer
UNION ALL
SELECT 'dim_product', count(*) FROM dim_product
UNION ALL
SELECT 'fct_order_line', count(*) FROM fct_order_line;
The fan-out check you should always run. If fct_order_line came out with MORE rows than stg_order_items, a join duplicated rows - usually a dimension with a repeated natural key. The b2 duplicate pattern (GROUP BY key HAVING count > 1) on the dimension finds the culprit in seconds. Grain declared, grain verified.
The payoff: founder questions as one-join queries ★ 10 min · build your own
These playgrounds start with the finished star (data-setup="star", which also includes dim_date - built properly in b4). Every question is the same shape: measures from the center, one join per arm you slice by. Feel how little SQL each answer needs now.
Run each query as written, then bend it: swap the dimension attribute you group by, or drop the status filter and watch refunds sneak into revenue.
Notice what you never do: no CAST, no quantity * unit_price, no five-table joins. Staging and the star already paid those taxes.
LiveQ1 · Revenue by month - join the date arm4 min▶
SELECT d.year, d.month_name,
ROUND(SUM(f.line_revenue), 2) AS revenue
FROM fct_order_line f
JOIN dim_date d ON f.date_key = d.date_key
WHERE f.status = 'completed'
GROUP BY d.year, d.month, d.month_name
ORDER BY d.year, d.month;
LiveQ2 · Revenue by city - join the customer arm3 min▶
SELECT c.city, c.country,
ROUND(SUM(f.line_revenue), 2) AS revenue
FROM fct_order_line f
JOIN dim_customer c ON f.customer_key = c.customer_key
WHERE f.status = 'completed'
GROUP BY c.city, c.country
ORDER BY revenue DESC;
Self-studyQ3 · Top products - join the product arm3 min▶
SELECT p.name, p.category,
SUM(f.quantity) AS units,
ROUND(SUM(f.line_revenue), 2) AS revenue
FROM fct_order_line f
JOIN dim_product p ON f.product_key = p.product_key
WHERE f.status = 'completed'
GROUP BY p.name, p.category
ORDER BY revenue DESC
LIMIT 3;
Try it yourself - this week ◐ 20-30 min total
- Write the grain sentence for a subscriptions fact ("one row = one ___") and list its measures and dimension arms. You will build it for real in b7.
- On the star setup, answer: which channel sells more Equipment, web or app? Two arms this time - dim_product for category, channel is already on the fact.
- Take any spreadsheet or table you use at work and label every column M (measure), A (attribute), or K (key). Where labels feel forced, you have found a grain problem.
- Explain Inmon vs Kimball to a colleague in two sentences each, then say which road their company is on. Most people have never noticed they are on one.
- Bring your star to b4 - the dimensions get their full treatment next: surrogate keys defended, dim_date built properly, conformed dims explained.
Official sources covered
This session teaches the dimensional-modeling core of the major curricula as one live build. Certificates, graded labs, and videos stay on the official platforms. This page covers:
Three questions before you go 🎯 ◐ 90 seconds
1 · What is the "grain" of a fact table?
Grain is a sentence, not a statistic. Declare it first and every column decision follows; skip it and mixed-grain rows quietly corrupt every SUM downstream.
2 · Which of these belongs on the fact table rather than a dimension?
Measures - numeric, summable, born at the event - live on the fact. City and roast are attributes: context you filter and group by, at home on dim_customer and dim_product.
3 · Demo 1 stamped dimensions with row_number() surrogate keys instead of reusing customer_id. The deeper reason, coming in b4, is...
Natural keys belong to the source system: they can change, collide across sources, and can only ever point at one version of a customer. Surrogate keys fix all three - b4 opens with this argument, and b5 (slowly changing dimensions) depends on it.