Skip to main content

Facts, Dimensions, and Grain

Level 3: Level 3: Data Modeling & Schema Designhard35 minfact vs dimensiongrain declarationsurrogate keysstar vs snowflake

Split analytics data into a narrow fact and wide dimensions around a declared grain.

Two kinds of table, one shape

Full 3NF is great for safe writes but painful for analytics: a "revenue by category by month" report might join six tables. The star schema (Ralph Kimball's dimensional model) is the disciplined denormalization that BI runs on. It has exactly two kinds of table:

  • Fact table: narrow and tall. Holds the measures (numeric, additive things you sum: revenue, quantity) plus foreign keys to dimensions. One fact table, millions of rows, few columns. fact_sales(customer_sk, product_sk, date_sk, quantity, revenue_cents).
  • Dimension tables: wide and short. Hold the descriptive context you filter and group by: dim_customer(customer_sk, name, country, segment), dim_product(product_sk, name, category, brand), dim_date(date_sk, date, month, year, weekday).

Drawn out, the fact sits in the center with dimensions radiating around it, hence the name star schema:

Schema
dim_customer
  • customer_sk
  • name
  • country
dim_date
  • date_sk
  • year_month
fact_sales
  • sale_sk
  • customer_sk
  • date_sk
  • product_sk
  • revenue_cents
dim_product
  • product_sk
  • category
  • fact_salesn-1dim_customercustomer_sk
  • fact_salesn-1dim_datedate_sk
  • fact_salesn-1dim_productproduct_sk
Star schema: a narrow fact_sales (measures + *_sk FKs at one declared grain) surrounded by wide surrogate-keyed dimensions.

Grain: declare it first, always

The grain is the precise meaning of one fact row: "one row per order line item," or "one row per order," or "one row per customer per day." Everything depends on getting this right: a measure only makes sense at a stated grain, and mixing grains double-counts. Kimball's first rule: declare the grain before you add a single column. Our fact's grain: one row per order line item.

Worked example, a minimal star:

-- Dimensions: wide, descriptive, surrogate-keyed
CREATE TABLE dim_product (
    product_sk   INTEGER PRIMARY KEY,   -- surrogate
    product_id   INTEGER,               -- natural/business key
    product_name TEXT,
    category     TEXT
);
CREATE TABLE dim_date (
    date_sk INTEGER PRIMARY KEY,        -- e.g. 20260105
    date    TEXT,
    year_month TEXT                     -- 'YYYY-MM'
);
-- Fact: narrow, measures + FKs to dims, at line-item grain
CREATE TABLE fact_sales (
    sale_sk    INTEGER PRIMARY KEY,
    product_sk INTEGER REFERENCES dim_product(product_sk),
    date_sk    INTEGER REFERENCES dim_date(date_sk),
    quantity   INTEGER,
    revenue_cents INTEGER
);

A BI query is now a couple of joins from the fact out to the dims:

SELECT p.category, d.year_month, SUM(f.revenue_cents) AS revenue
FROM fact_sales f
JOIN dim_product p ON p.product_sk = f.product_sk
JOIN dim_date    d ON d.date_sk    = f.date_sk
GROUP BY p.category, d.year_month;

Anatomy of the star:

          dim_customer
                │
 dim_date ── fact_sales ── dim_product
                │
          (measures: quantity, revenue_cents; FKs: *_sk)

  fact  = numeric measures + dimension FKs, at ONE declared grain
  dim   = descriptive attributes you filter/group by, surrogate-keyed

Star vs snowflake

A star keeps each dimension flat (denormalized): dim_product holds category right on it. A snowflake normalizes dimensions further (dim_product → dim_category), saving space but re-introducing joins. Kimball's default is star: flatter dims, fewer joins, faster and simpler for analysts. Snowflake only when a dimension is huge and its sub-attributes are heavily reused.

Surrogate keys everywhere. Dimensions use surrogate *_sk keys, and the fact references those, not the business keys. This is what makes slowly-changing dimensions possible later: the surrogate can point at the version of a dimension valid when the fact happened.

In the warehouse this differs. This is the warehouse's home turf. Star schemas are the native modeling pattern of Snowflake, BigQuery, Redshift, and dbt projects. The SQL you write here is exactly what you'd write there (minus the un-enforced FKs). dim_date in particular is a warehouse staple: a pre-built calendar dimension every fact joins to.

Keep it readable / common pitfall. The #1 pitfall is not declaring the grain and then mixing grains in one fact (e.g. line-item rows and order-total rows). Every sum double-counts. State the grain in a comment at the top of the fact DDL. Second pitfall: stuffing descriptive text into the fact ("just this once"). Descriptions belong in dimensions; the fact stays numeric and narrow.

Recap: a star schema is one narrow measure-and-FK fact surrounded by wide descriptive dimensions, all surrogate-keyed, around a single declared grain: the model BI queries are built for; default to star (flat dims) over snowflake.

Execution mode: you write a multi-statement DDL+DML script. It runs against a fresh in-memory SQLite DB, then hidden assertion queries check the star's shape, grain, and that every fact FK resolves.

Apply

Your turn

The task this lesson builds to.

Build a minimal star schema. Create dim_product with a surrogate key product_sk (plus the business key product_id, and name, category), and a narrow fact_sales at one-row-per-sale grain that references dim_product via product_sk and carries only quantity and revenue_cents. Load the two products and three sales from the raw_* seed, then materialize the BI query "revenue by category" as a table named cat_revenue(category, revenue).

4 hints and 5 automated checks are waiting in the workspace.

Practice

Make it stick

A second problem on the same idea, so it survives past today.

Convert the normalized 3NF seed (customers, products, orders, order_items) into a first star schema:

  • dim_customer(customer_sk PK, customer_id, email, country_code)
  • dim_product(product_sk PK, product_id, product_name, category)
  • dim_date(date_sk PK, full_date, year_month): derive date_sk as an integer YYYYMMDD, year_month as 'YYYY-MM', one row per distinct order date.
  • fact_sales at line-item grain: (sale_sk PK, customer_sk, product_sk, date_sk, quantity, revenue_cents), each FK looked up from its dimension by natural key, with zero orphan facts.

Then materialize one BI query as revenue_by_cat_month(category, year_month, revenue) computing revenue by category by month over the star.

4 hints and 6 automated checks are waiting in the workspace.