Skip to main content

Dimensional modelling β€” star and snowflake

⏱ 35 min

Hook​

You inherit a warehouse table called sales with 140 columns. Some rows are one order, some are one order line, and a few are daily totals that a well-meaning analyst appended. Every query starts with an argument about what a row means, and half of them double-count. The table was never wrong on any single insert β€” it just never decided what one row represents. That decision has a name, and making it first is the whole discipline of dimensional modelling.

Concept​

Dimensional modelling organizes analytics data into two kinds of table: facts, which hold the measurements of a business process, and dimensions, which hold the descriptive context you slice those measurements by. A fact table row is a measurement event β€” a sale, a click, a shipment β€” carrying numeric measures (quantity, revenue) and foreign keys to dimensions. A dimension table holds the "who, what, where, when" β€” customer, product, store, date β€” with one row per real-world thing and lots of descriptive columns.

The single most important decision is the grain: the precise meaning of one fact-table row, stated in a sentence before any columns are chosen. "One row per order line item." "One row per customer per day." "One row per shipment." Fix the grain first, and everything else follows: the dimensions are whatever context is true at that grain, and the measures are whatever is additive at that grain. Skip it, and you get the 140-column table where nobody can trust a SUM.

tip

Write the grain as a sentence and put it in a comment above the fact table. Every measure you add must be additive at that grain; if it is not (a ratio, a percentage, a running balance), store the additive parts and compute the ratio at query time.

A star schema is the layout that results: one fact table in the middle, dimensions around it, each joined by a single key. It is called a star because of its shape.

Dimensions in a star are denormalized on purpose: dim_product stores the category name as a plain column, repeated on every product in that category. That repetition is the point β€” a query filters by category with zero extra joins.

A snowflake schema normalizes a dimension back out into sub-tables: instead of a category column on dim_product, you get a dim_category table that dim_product references by key. It saves a little storage and keeps category names in one place, at the cost of an extra join every time you slice by category.

AspectStarSnowflake
Dimension shapeDenormalized, wide, flatNormalized into sub-dimension tables
Joins to sliceOne per dimensionOne per level of the snowflake
Read performanceFaster β€” fewer joinsSlower β€” more joins
Storage/redundancyMore redundant attribute valuesLess redundancy
When it fitsThe default for query speedHuge dimensions, or attributes reused across many dimensions

The practitioner default is the star. Reach for a snowflake only when a dimension is genuinely enormous or a sub-hierarchy is shared across several dimensions and must stay consistent β€” and even then, know you are trading read speed for it.

🧠 Knowledge check
1. Why is declaring the grain the first decision in dimensional modelling?
2. What distinguishes a snowflake schema from a star schema?

Worked example​

Let me build a tiny star and show two things: that the grain holds, and that a dimension lets me slice by an attribute the fact never stores. The grain here is one row per product per store per day.

import sqlite3

con = sqlite3.connect(":memory:")
con.executescript("""
CREATE TABLE dim_product (
product_key INTEGER PRIMARY KEY,
product_name TEXT,
category TEXT
);
CREATE TABLE dim_store (
store_key INTEGER PRIMARY KEY,
store_name TEXT,
region TEXT
);
-- Grain: one row per product per store per day.
CREATE TABLE fact_sales (
sale_key INTEGER PRIMARY KEY,
product_key INTEGER,
store_key INTEGER,
sale_date TEXT,
quantity INTEGER,
net_amount REAL
);
INSERT INTO dim_product VALUES
(1, 'Yirgacheffe 250g', 'Single Origin'),
(2, 'House Blend 500g', 'Blend');
INSERT INTO dim_store VALUES
(1, 'Riverside', 'West'),
(2, 'Old Town', 'East');
INSERT INTO fact_sales VALUES
(1, 1, 1, '2026-06-01', 2, 28.00),
(2, 2, 1, '2026-06-01', 1, 18.00),
(3, 1, 2, '2026-06-02', 3, 42.00),
(4, 2, 2, '2026-06-02', 1, 18.00);
""")

First, prove the grain. If one row really is one product-store-day, then no combination of those three keys appears more than once:

grain = con.execute("""
SELECT MAX(c) FROM (
SELECT COUNT(*) AS c
FROM fact_sales
GROUP BY product_key, store_key, sale_date
)
""").fetchone()[0]
print("max rows per grain key:", grain)
max rows per grain key: 1

A maximum of 1 means the declared grain holds β€” every measurement lands in its own row, so a SUM cannot double-count. Now the payoff of the dimension: slice revenue by region, a column the fact table never stores. The join to dim_store supplies it:

rows = con.execute("""
SELECT s.region, SUM(f.net_amount) AS revenue
FROM fact_sales f
JOIN dim_store s ON s.store_key = f.store_key
GROUP BY s.region
ORDER BY revenue DESC
""").fetchall()
for row in rows:
print(row)
con.close()
('East', 60.0)
('West', 46.0)

The fact holds only measures and keys; the dimension holds the descriptive region. One join turns store-level rows into a regional total. Because dim_store is flat, that slice cost exactly one join β€” the star schema's whole value in one query. Snowflake region out into a dim_region table and the same report would cost a second join for no analytical gain at this size.

Hands-on​

Your turn, on a different process. The exercise below gives you a normalized support-ticket source and asks you to design a star schema for it: declare the grain as one row per ticket resolution, build a fact_resolution with the resolution-time measure, and add dim_agent and dim_priority dimensions. Then slice mean resolution time by priority.

β–Ά SQL Kingdomde201-design-ticket-star
Practice in SQL Kingdom β†—

Success criteria: your fact table has exactly one row per resolved ticket, the priority label comes from a dimension rather than the fact, and the by-priority query returns one row per priority level. SQL Kingdom validates the grain and the slice automatically.

Recap​

  • You can state the grain of a fact table as a sentence and verify it holds, so aggregations never double-count.
  • You can separate facts (measures plus keys) from dimensions (descriptive context) and lay them out as a star.
  • You can explain when a snowflake's extra join is worth paying for and why the star is the default for query speed.

Next up: dimensions are not frozen. A customer moves, a product is recategorized β€” and last year's facts must still join to the attribute that was true when they happened. That is the slowly changing dimension, and its loading patterns close out this slice.

🧠 Knowledge check
1. A fact table has grain "one row per order line item." Which belongs as a measure on that fact?
2. When is choosing a snowflake over a star most defensible?
πŸ“Œ Key takeaways
  • Declare the fact grain as a sentence first; it determines valid dimensions and which measures are additive.
  • A star schema puts one fact table among flat, denormalized dimensions, each a single join away.
  • Snowflaking normalizes a dimension to cut redundancy at the cost of joins β€” worth it only for huge or shared dimensions.