learn-data-warehouse-with-phoebe / Builder session 3 of 10
Learn Data Warehouse with Phoebe · Builder track · Session 3 of 10

Facts and dimensions

Staging gave Daybreak trusted copies - but a trusted copy of OLTP is still shaped like OLTP. Tonight you learn the modeling move the whole field is named after: split the world into facts (events with numbers) and dimensions (nouns with context), arrange them as a star, and watch the founder's hardest questions collapse into one-join queries. You will build the star live, on the staging layer you poured in b2.

🟡 Builder track Practitioners: analysts · DE · DS · PMs Runs in your browser · DuckDB 45 min
0-3 · Recap 3-20 · Grain, facts, dims & schools 20-42 · Build-along: the star 42-45 · Q&A
Part 0

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.

Live - presented in session Self-study - read after class ▶ Live warehouse - editable & runnable Official sources covered
★ What you walk out with today The one-sentence habit that prevents most modeling disasters (declare the grain first), a working definition of fact vs dimension you can apply to any dataset, the Inmon vs Kimball map, and Daybreak's first star schema - built by you, queried by you, live.
Part 1 · covers DLAI C4 M1 star schema, IBM M2 facts & dimensional modeling

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).

dim_date date_key (PK) year · month · weekend dim_customer customer_key (PK) name · city · plan dim_product product_key (PK) name · category · roast fct_order_line grain: one row = one order line customer_key · product_key date_key · order_id quantity · unit_price · revenue status · channel who bought what they bought when it happened Amber row = MEASURES: the numbers you sum and average. Blue rows = keys and context riding on the fact. Every founder question becomes: pick measures from the center, slice by any arm. One join per arm.
🔍 Click to zoom - Daybreak's star: one fact in the middle, three dimensions as arms
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.
Part 2 · covers DLAI Inmon vs Kimball & normalization

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.

Inmon · enterprise first sources normalized core (3NF) dimensional marts Model the WHOLE enterprise in normal form first; serve teams from marts carved off it. Strength: one integrated truth. Cost: months before the first dashboard ships. Kimball · dimensional from day one sources staging stars on conformed dimensions Build a star per business process, sharing one set of dimensions (the "bus"). Strength: answers this quarter. Cost: discipline - dims must stay conformed (b4). This track walks Kimball's road Daybreak needs answers this quarter, not a two- year enterprise model. Most modern teams start here for the same reason. Third option exists: One Big Table - everything denormalized flat. When that wins (and loses) is b9.
🔍 Click to zoom - two schools, one choice for Daybreak: Kimball, because answers are due this quarter
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.

Why denormalize at all, after the SQL course taught normalizing? Apps optimize for safe single-row writes: normalize so every fact is stored once. Analytics optimizes for wide reads: copy context onto dimensions so a question is one join instead of seven. Same data, opposite pressures - both designs are correct for their job.
Part 3 · from OLTP to star

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 columnStar homeWhy
order_items.quantity, unit_pricemeasures on fct_order_linenumeric, summable, at the grain
stg_order_items.line_amountmeasure line_revenue on the factcomputed once in staging, renamed for the business
customers.name, city, country, planattributes on dim_customercontext about WHO - you group by these, never sum them
products.name, category, roast, priceattributes on dim_productcontext about WHAT
orders.order_datedate_key on the fact, joining dim_dateWHEN - the calendar becomes its own dimension (b4)
orders.status, channelattributes ON the factlow-cardinality event context - not worth a dimension yet
orders.order_iddegenerate dimension on the factan ID with no attributes of its own - rides along for tracing
Real world

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.

Demo 1 of 2

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;
Real world

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.

Demo 2 of 2

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;
Homework

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

Source material

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:

DeepLearning.AI Data Engineering C4 - Module 1: star schema, Inmon vs Kimball, normalizationParts 1-2 · both schools mapped, Kimball road chosen and justified
IBM Data Warehouse Fundamentals (Coursera) - Module 2: facts & dimensional modelingParts 1 + 3 + Demo 1 · lab twin: their modeling exercise, on Daybreak
365DS Intro to Data Warehousing - dimensional modeling & star schemasParts 1 + 3 · grain, measures vs attributes, star anatomy
365DS - advanced schemas (snowflake, galaxy)Part 2 note · One Big Table and schema variants land in b9
Check yourself

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.

Builder session 3 cheat sheet · pin this

GrainOne sentence: "one row of this fact = one ___". Say it before any SQL. Finest level the business will ask about.
Fact tableEvents with numbers: measures at the grain + foreign keys to each dimension. Daybreak's: fct_order_line.
DimensionNouns with context: one row per customer / product / day, wide descriptive attributes you group and filter by.
Measure vs attributeCan you SUM it meaningfully? Measure, on the fact. Do you GROUP BY it? Attribute, on a dimension.
Star schemaOne fact in the center, one join per dimension arm. Every founder question = measures + slices.
Inmon vs KimballInmon: normalized enterprise core first, marts after. Kimball: stars + conformed dims from day one. This track: Kimball.
Degenerate dimensionAn ID (order_id) kept on the fact with no dimension table of its own - nothing to describe, useful for tracing.
Next sessionb4 builds dimensions properly: surrogate keys defended, dim_date from generate_series, conformed dims.