learn-business-intelligence-with-phoebe / Builder session 3 of 10
Learn Business Intelligence with Phoebe · Builder track · Session 3 of 10

Model like a pro: the star schema

In b2 you cleaned Daybreak's data. Tonight you arrange it - one fact table in the middle, dimensions around it, like a star. This is the shape every BI tool secretly wants, the shape the PL-300 exam tests hardest, and the vocabulary of every BI interview you will ever sit. We build the star with real SQL views, live in your browser.

🟡 Builder track Practitioners: analysts · DE · DS · PMs Runs in your browser · no install 45 min live + self-study
0-3 · Recap 3-18 · Facts, dims, relationships 18-42 · Build-along: build the star 42-45 · Q&A
Part 0

Why modeling is the session that matters

Stage 3 of the five-stage workflow: Model. Connect and prepare got the data in and clean; now we decide its shape. Get the shape right and every measure in b4 is a one-liner and every chart in b5-b6 is a drag-and-drop. Get it wrong and you fight double counting forever. The industry has converged on one answer - the star schema - and tonight Daybreak gets one.

Live - presented in session Self-study - read after class ▶ Mini-BI - interactive playground Official sources covered
★ What you walk out with today Daybreak modeled as a star: one fact table (fact_sales) and four dimensions (customer, product, date, channel), built as real SQL views you can query. Plus the words that come up in every BI interview - fact, dimension, grain, cardinality, cross-filter direction, role-playing dimension - each attached to something you built, not memorized.
Part 1 · covers PL-300 "create fact and dimension tables", "design a data model"

Facts and dimensions 7 min live

Every business question decomposes the same way: a number you measure sliced by things you describe. The numbers live in a fact table - events that happened: an order line, with its quantity and price. The descriptions live in dimension tables - the who, what, where, and when you slice by: customer, product, date, channel. Put the fact in the middle, dimensions around it, connect with keys - that is the star.

dim_customer name · city · country plan · signup_date dim_product name · category roast · price dim_date year · month weekday · is_weekend dim_channel channel name web · app · retail fact_sales one row = one order line order_id · product_id customer_id · order_date channel · quantity unit_price · line_revenue 1 * 1 * 1 * 1 * Each dimension row (1) matches many fact rows (*). Measures live in the middle; slicers live around it.
🔍 Click to zoom - THE star: fact_sales built from order_items + orders, four dimensions around it
LiveWhy not one big flat table4 min

The tempting alternative: join everything into one wide table and chart from that. It works for a week, then hurts four ways:

  • Repeats: a customer's city is copied onto every order line they ever bought. Fix a typo in one place, it survives in a thousand others.
  • No reuse: next month you build a subscriptions report. The flat table was built for orders - you start from zero. Dimensions built once get reused by every fact you add later.
  • Measure errors: flat tables mix grains. Put monthly_qty from subscriptions next to order lines and SUM happily double counts. Stars force one grain per fact.
  • Memory: BI engines compress repeated dimension values brilliantly when they live in their own narrow tables - a star is smaller and faster than the flat version of itself.
Real world

The 40-column export. Most "our dashboard is slow and the numbers are wrong" tickets trace back to one giant flat extract someone built in year one. The fix is never a faster refresh - it is remodeling into a star. Cheaper to do it on day one, which is today.

Self-studyGrain - the one-sentence contract3 min read

The grain is the answer to: what does one row of the fact table represent? For fact_sales the sentence is: "one row = one order line". Write that sentence before you write any SQL, and put it in the table's documentation forever.

  • Misdeclared grain = double counting. If someone believes the grain is "one row = one order" and sums a column that repeats per line, every multi-item order counts twice or more. The single most common wrong-number bug in BI.
  • One grain per fact. Orders at line grain and subscriptions at month grain do not belong in the same fact table. They become two facts sharing the same dimensions - still one star family, two centers.
  • The grain picks your measures. At line grain, revenue is SUM(quantity * unit_price) and it is safe. Coarser grains lose detail you can never recover.
Part 2 · covers PL-300 "relationship cardinality and cross-filter direction", "common date table"

Relationships: cardinality and direction 7 min live

Lines between tables carry two settings every BI tool asks you for. Cardinality: one-to-many is the healthy default - one dimension row (1) matches many fact rows (*). Cross-filter direction: filters usually flow one way, dimension → fact; you click a city, the fact rows filter. Both-directions filtering exists, and it is a last resort - it invites ambiguity and slow models. When an interviewer asks about relationships, "1-to-many, single direction, dim filters fact" is the right reflex.

1 : * - the healthy default dim_product fact_sales 1 * One product, many lines. Filter flows dim → fact, single direction. Ship it. * : * - handle with care customers products * * Ambiguous filters, weird totals. Resolve through a fact or bridge table. Role-playing date dim_date as order date as signup date One date table, two roles. Build the calendar once, relate it per role. Default: 1-to-many, single direction, dim filters fact. Both-directions cross-filter is a last resort.
🔍 Click to zoom - the three relationship patterns the PL-300 loves to test
LiveThe date table - why BI tools want a dedicated one4 min

Dates hide inside every table, so why build a separate dim_date? Because a common date table gives you three things raw date columns never will:

  • Continuous days: one row per calendar day, even days with zero orders. Without it, a month with no sales silently vanishes from your trend line instead of showing as zero.
  • Ready-made attributes: year, month, quarter, weekday, is_weekend - computed once, reused by every chart, always spelled the same way.
  • Time intelligence: year-to-date, same-month-last-year, rolling 3 months - the b4 measures - all require a proper date table underneath. No date table, no time intelligence.

SQLite has no calendar generator built in, but here is the seed of one - the distinct months Daybreak has traded. A real dim_date extends this to one row per day:

SELECT DISTINCT strftime('%Y-%m', order_date) AS month
FROM orders
ORDER BY 1;
Self-studyRole-playing dimensions2 min read

Daybreak has order dates (orders.order_date) and signup dates (customers.signup_date). Do you build two date tables? No - you build one dim_date and relate it twice, once per role. The same physical table "plays the role" of order date in one relationship and signup date in another. In Power BI the second relationship is created inactive and activated per measure; in Tableau you simply relate the field you need. One calendar, many roles - that is the whole trick, and it is a named PL-300 topic.

In Power BI / In Tableau Power BI: build relationships in Model view, set cardinality and cross-filter direction in the relationship dialog, and use Mark as date table on dim_date so time intelligence works. Tableau: prefer logical-layer relationships (Tableau figures out join types per viz) over physical joins; physical joins are the old, stiffer way. Same star, two dialects.
Part 3 · the star, made real

Build the star as views 4 min live

In Power BI or Tableau you would click this together in a model view. Here we make it real with SQL: a view is a saved query that behaves like a table - exactly what a modeled fact is underneath. fact_sales combines order_items (the measures) with orders (the keys and context), at one-row-per-order-line grain. Remember: every run button starts from a fresh database, so the CREATE VIEW and the SELECT live in the same box.

Livefact_sales, born4 min

Run it, then read the output columns against the star diagram above - they match one for one.

CREATE VIEW fact_sales AS
SELECT oi.order_id,
       oi.product_id,
       o.customer_id,
       o.order_date,
       o.status,
       o.channel,
       oi.quantity,
       oi.unit_price,
       oi.quantity * oi.unit_price AS line_revenue
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id;

SELECT * FROM fact_sales LIMIT 8;
★ The one idea to keep A "data model" is not magic - it is saved joins plus a declared grain. The view above IS a modeled fact table. BI tools add a visual editor and a cache on top, but what you just built is structurally what ships inside every Power BI semantic model.
Demo 1 of 2

Query the star vs the raw tables ★ 12 min · everyone builds

Same question - revenue by product category - answered twice. First the raw way: three tables, two joins, join logic on you every single time. Then the star way: the fact view plus one dimension. Same number, half the joins. That difference, multiplied by every chart on every dashboard, is why BI models are stars.

The raw way. Run the three-table version below. Count the joins and imagine typing them correctly in every report, forever.

The star way. Run the second box: create fact_sales, then answer the same question with one join to products. Compare the revenue numbers - identical.

Say it out loud. "The star pre-pays the join cost once, so every question afterwards is cheap." That sentence is the whole business case for modeling.

LiveRaw tables: three tables, two joins4 min
SELECT p.category,
       ROUND(SUM(oi.quantity * oi.unit_price), 2) AS revenue
FROM order_items oi
JOIN orders o   ON o.order_id   = oi.order_id
JOIN products p ON p.product_id = oi.product_id
GROUP BY 1
ORDER BY 2 DESC;
LiveThe star: fact + one dimension4 min
CREATE VIEW fact_sales AS
SELECT oi.order_id, oi.product_id, o.customer_id, o.order_date,
       o.status, o.channel, oi.quantity, oi.unit_price,
       oi.quantity * oi.unit_price AS line_revenue
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id;

SELECT p.category,
       ROUND(SUM(f.line_revenue), 2) AS revenue
FROM fact_sales f
JOIN products p ON p.product_id = f.product_id
GROUP BY 1
ORDER BY 2 DESC;
Demo 2 of 2

Your turn ★ 10 min · build your own

Two exercises: one where you extend the star with a dimension we have not touched yet, and one where you catch the playground red-handed doing exactly what you just learned.

LiveQ1 · Revenue by plan, through the star4 min

customers is a dimension too. Join it to fact_sales and slice revenue by plan - the skeleton is ready, run it, then try swapping plan for city or country.

CREATE VIEW fact_sales AS
SELECT oi.order_id, oi.product_id, o.customer_id, o.order_date,
       o.status, o.channel, oi.quantity, oi.unit_price,
       oi.quantity * oi.unit_price AS line_revenue
FROM order_items oi
JOIN orders o ON o.order_id = oi.order_id;

SELECT c.plan,
       ROUND(SUM(f.line_revenue), 2) AS revenue
FROM fact_sales f
JOIN customers c ON c.customer_id = f.customer_id
GROUP BY 1
ORDER BY 2 DESC;
LiveQ2 · Catch the playground using your star4 min

The mini-BI has been quietly running this same star logic since session b1. Build revenue by category below, press Show SQL, and read the joins - order_items to orders to products. That is your star, just written inline. A production semantic model does exactly this on your behalf, thousands of times a day.

Homework

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

Source material

Official sources covered

Modeling is the heaviest-weighted domain on the PL-300 (25-30% of the exam) and the conceptual heart of the Tableau and Google curricula. This page covers:

PL-300 · Design and implement a data modelParts 1-3 · cardinality, cross-filter direction, common date table, role-playing dimensions
PL-300 · Create fact and dimension tables, identify keysParts 1 + 3 · fact_sales built live, keys traced through every join
MS Learn · Configure a semantic model in Power BIConcepts here; Desktop click-paths and DAX specifics stay with Microsoft
Kimball · dimensional modeling canonThe spirit - facts, dims, grain, roles - not the full book; slowly changing dimensions return in b10
Check yourself

Three questions before you go 🎯 ◐ 90 seconds

1 · What belongs in a fact table?

Facts hold the events you measure, at one declared grain. Descriptive attributes are dimensions (A), and the calendar is dim_date (C).

2 · The healthy default relationship between a dimension and a fact is...

One dimension row matches many fact rows, and filters flow dim → fact. Both-directions and many-to-many exist but are last resorts - ambiguity and slow models follow them around.

3 · A "role-playing dimension" means...

One table, multiple relationship roles. You build the calendar once and relate it per role - two copies (B) is exactly what role-playing saves you from.

Builder session 3 cheat sheet · pin this

Fact tableMeasurable events at one declared grain. Daybreak: fact_sales = order_items + orders keys.
DimensionThe who/what/where/when you slice by: customer, product, date, channel. Built once, reused by every fact.
GrainOne sentence: "one row = one order line." Misdeclared grain = double counting, the classic BI bug.
Star vs flatFlat tables repeat, mix grains, and miscount. Stars pre-pay the join cost once; every question after is cheap.
Cardinality1-to-many is the healthy default: dim (1) → fact (*). Many-to-many needs a bridge.
Cross-filterSingle direction, dim filters fact. Both directions = last resort, invites ambiguity.
Date tableDedicated, continuous, one row per day, marked as date table. Time intelligence (b4) depends on it.
Role-playing dimOne dim_date, related twice: order date and signup date. One calendar, many roles.