Education › Data › Stage 1: Foundations

Data modelling

Normalisation, star and snowflake schemas, slowly changing dimensions, and choosing a grain.

Beginner ~35 min read Module 2 of 16

A pipeline moves data; a model decides what the data means. Get the model wrong and every query downstream fights it: joins that fan out, metrics that disagree between dashboards, a history nobody can reconstruct. Get it right and most analytics questions become a short query on an obvious table. This module covers the two modelling traditions you will meet — normalised schemas for systems that write, dimensional schemas for systems that read — and the decisions that matter in practice: choosing a grain, handling change over time with slowly changing dimensions, keys, and the modern variations that columnar warehouses make possible.

After this module you can
  • Explain normalisation and why transactional systems use it
  • Design a star schema: declare a grain, separate facts from dimensions, choose surrogate keys
  • Handle change over time with the right slowly changing dimension type
  • Model events, snapshots and accumulating facts appropriately
  • Decide when to denormalise, when to use wide tables, and how to keep one definition per metric

Normalised: the shape of a system that writes

Operational databases are normalised: each fact is stored once, in the table that owns it, and related by keys. A customer's address lives in one row; an order references the customer by id; changing the address touches one place. The normal forms formalise this: no repeating groups (1NF), every non-key column depends on the whole key (2NF), and on nothing but the key (3NF). The payoff is write integrity: no update anomalies, no inconsistent copies. The cost is that answering a question means joining five tables, and the schema reflects how the application works, not how the business thinks.

A normalised order model: each entity once, relationships by key, constraints enforced by the database.
sql
CREATE TABLE customers (
  id          bigint PRIMARY KEY,
  email       text NOT NULL UNIQUE,
  created_at  timestamptz NOT NULL
);
CREATE TABLE products (
  id     bigint PRIMARY KEY,
  sku    text NOT NULL UNIQUE,
  name   text NOT NULL,
  price  numeric(12,2) NOT NULL
);
CREATE TABLE orders (
  id           bigint PRIMARY KEY,
  customer_id  bigint NOT NULL REFERENCES customers(id),
  ordered_at   timestamptz NOT NULL,
  status       text NOT NULL CHECK (status IN ('placed','paid','shipped','refunded'))
);
CREATE TABLE order_items (
  order_id    bigint NOT NULL REFERENCES orders(id),
  product_id  bigint NOT NULL REFERENCES products(id),
  quantity    int NOT NULL CHECK (quantity > 0),
  unit_price  numeric(12,2) NOT NULL,   -- price at time of order, not a lookup
  PRIMARY KEY (order_id, product_id)
);

Notice unit_price on the item: it is deliberately a copy, because the product's price changes and the order must remember what was charged. Normalisation is a default, not a law; the exceptions are chosen for a reason and documented.

Dimensional: the shape of a system that reads

Analytics wants the opposite trade: fast, simple reads over many rows, and a schema that matches how people ask questions ("revenue by product category by month by region"). The star schema delivers that. A fact table holds measurements at a declared grain, one row per event or per thing measured, with numeric measures and foreign keys. Dimension tables hold the descriptive context (who, what, where, when) with one row per entity and many descriptive columns. Queries filter and group by dimension attributes and aggregate fact measures; joins are always fact-to-dimension on a single key.

The first and most important decision is the grain: exactly what one row of the fact table represents. "One row per order line" and "one row per order" are different tables with different measures, and mixing grains in one table is the root of most fan-out and double-counting. Declare the grain in the table's documentation and make the fact table's primary key express it.

A sales star at the order-line grain, with surrogate keys and a conformed date dimension.
sql
CREATE TABLE dim_date (
  date_key   int PRIMARY KEY,       -- 20260301
  date       date NOT NULL,
  year int, quarter int, month int, month_name text, day_of_week text, is_weekend boolean, fiscal_period text
);
CREATE TABLE dim_customer (
  customer_key   bigint PRIMARY KEY,   -- surrogate
  customer_id    bigint NOT NULL,      -- natural key from the source
  email text, country text, segment text, signup_date date,
  valid_from timestamptz NOT NULL, valid_to timestamptz, is_current boolean NOT NULL
);
CREATE TABLE dim_product (
  product_key bigint PRIMARY KEY, product_id bigint NOT NULL,
  sku text, name text, category text, brand text
);
CREATE TABLE fact_sales (
  -- grain: one row per order line
  order_id       bigint NOT NULL,
  line_no        int    NOT NULL,
  date_key       int    NOT NULL REFERENCES dim_date(date_key),
  customer_key   bigint NOT NULL REFERENCES dim_customer(customer_key),
  product_key    bigint NOT NULL REFERENCES dim_product(product_key),
  quantity       int NOT NULL,
  unit_price     numeric(12,2) NOT NULL,
  line_amount    numeric(12,2) NOT NULL,
  PRIMARY KEY (order_id, line_no)
);
date_keycustomer_keyproduct_keystore_keyfilter + groupfact_salesgrain: one order linedim_datecalendar, fiscaldim_customerSCD2: versionsdim_productcategory, branddim_storeregion, countryQuerygroup dims, sum facts
A star schema: one fact table at a declared grain in the middle, joined by surrogate keys to flat dimension tables around it. Every analytic query filters and groups on dimension attributes and aggregates fact measures, and every join is fact-to-dimension on a single key.
Note

Surrogate keys (customer_key) are integers the warehouse assigns, independent of the source system's ids. They make history possible (one customer id, several versions), insulate the warehouse from source key changes, and keep fact tables narrow.

Change over time: slowly changing dimensions

A customer moves from Berlin to Lisbon. Should last year's orders now count as Portuguese revenue? The answer depends on the question, and slowly changing dimension (SCD) types are the standard ways to encode the choice. Type 1 overwrites: the dimension holds only the current value; history is lost, which is right for corrections (a typo in a name). Type 2 adds a new row per version with validity dates and a current flag; facts reference the version that was current when they happened, so historical reports are stable. Type 3 keeps a previous-value column for a limited comparison. Type 2 is the default for anything the business analyses over time.

Applying a Type 2 change: close the old version, insert the new one. Facts loaded later pick up the new key; old facts keep the old one.
sql
-- customer 1042 moved to Lisbon on 2026-03-01
UPDATE dim_customer
   SET valid_to = '2026-03-01', is_current = false
 WHERE customer_id = 1042 AND is_current;

INSERT INTO dim_customer (customer_key, customer_id, email, country, segment, signup_date, valid_from, valid_to, is_current)
SELECT nextval('dim_customer_key_seq'), customer_id, email, 'PT', segment, signup_date, '2026-03-01', NULL, true
  FROM dim_customer WHERE customer_id = 1042 AND valid_to = '2026-03-01';

-- point-in-time lookup when loading a fact
SELECT customer_key FROM dim_customer
 WHERE customer_id = 1042
   AND valid_from <= '2026-02-14' AND (valid_to IS NULL OR valid_to > '2026-02-14');

Type 2 tables grow with change and need care: a valid_to of NULL for the current row, non-overlapping ranges, exactly one current row per natural key — all worth testing (the data quality module does exactly this). In modern warehouses the snapshot feature of transformation tools automates Type 2 from a source table, which is how most teams implement it today.

Kinds of facts, and events

Not every fact table is a list of transactions. A transaction fact has one row per event (a sale, a click); it is the most common and the easiest to reason about. A periodic snapshot has one row per entity per period (account balance per day, inventory per warehouse per week); it answers "what was the state at time t" without replaying events. An accumulating snapshot has one row per process instance with a column per milestone (order placed, paid, shipped, delivered), updated as milestones occur; it makes funnel and lag analysis trivial.

Fact typeGrainTypical question
TransactionOne row per eventHow much did we sell yesterday by category?
Periodic snapshotOne row per entity per periodWhat was every account's balance on the 1st?
Accumulating snapshotOne row per process, milestone columnsMedian days from order to delivery, by region?

Event streams (product analytics, logs) are transaction facts at very fine grain, often with a flexible JSON payload. Model them with a stable core (event name, timestamp, user, session, device) as columns and the variable properties in a structured column, then build narrower, typed tables for the events the business actually analyses. Keep the raw events immutable and append-only; every downstream table can be rebuilt from them.

Modern variations and one definition per metric

Columnar warehouses changed some trade-offs. Wide, denormalised tables (One Big Table) with hundreds of columns are cheap to scan and remove joins for dashboards; they work well as a final presentation layer built from a star, not as the model itself. Nested and repeated columns (arrays, structs) let a line-item fact live inside its order row. The snowflake variant normalises dimensions (product to category to department) and mostly adds joins for little benefit; keep dimensions flat unless a sub-dimension is shared and large.

Whatever the shape, the property that matters most is one definition per metric. "Revenue" computed three ways in three dashboards is the most common trust failure in analytics. Define each metric once in the transformation layer (a model or a metrics layer), with its grain, filters and NULL handling stated, and have every consumer read that definition. The transformation module builds this; the modelling decision is to design the tables so the definition is simple to express.

A model document that states what future readers need. Two minutes to write, hours saved later.
text
fact_sales
  grain:      one row per order line (order_id, line_no)
  measures:   quantity, unit_price (as charged), line_amount = quantity * unit_price
  keys:       date_key (order date), customer_key (SCD2 as of order date), product_key
  excludes:   cancelled orders (status = 'cancelled'); refunds are separate rows in fact_refunds
  source:     orders + order_items, loaded incrementally by ordered_at
  metric:     revenue = SUM(line_amount) WHERE status IN ('paid','shipped')  -- the ONE definition
Hands-on practice

Model a small business end to end

  1. Take a domain you know (a coffee shop chain, a SaaS subscription business, a library). List the business questions people would ask: at least ten, in plain words.
  2. Write the normalised schema the operational application would use: four to six tables with keys and constraints, in SQL.
  3. Design the star: declare the fact grain in one sentence, list measures, and design three dimensions including a date dimension. Write the DDL.
  4. For the customer (or member, or account) dimension, decide which attributes are Type 1 and which are Type 2, and write the SQL that applies one Type 2 change.
  5. Load a few dozen rows of sample data into both schemas (DuckDB is enough) and answer three of your business questions against the star. Note how many joins each needed.
  6. Write the model document for the fact table in the lesson's format, including the one definition of the main metric.
  7. Add a periodic snapshot table for one entity (daily inventory, daily subscription status) and write the query that produces it from the transaction facts.
Cheat sheet

Data modelling — at a glance

Main things to focus on

  • Normalised for writes (each fact once, joins for reads); dimensional for reads (facts + dimensions, simple joins)
  • Declare the grain of every fact table in one sentence; the primary key should express it
  • Surrogate keys in dimensions; natural keys kept as attributes
  • SCD Type 2 for anything analysed over time: valid_from, valid_to, is_current, one current row per key
  • Transaction, periodic snapshot and accumulating snapshot facts answer different questions
  • One definition per metric, stated with grain, filters and NULL handling

Normal forms and constraints

1NF / 2NF / 3NFNo repeating groups / depends on whole key / on nothing but the key
PRIMARY KEY, UNIQUE, REFERENCES, CHECK, NOT NULLLet the database enforce the model
copy a value on purpose (unit_price at order time)Documented denormalisation for history
numeric(12,2) for moneyNever float

Star schema

fact_*: measures + foreign keys at one grainOne row per event or measurement
dim_*: one row per entity, many descriptive columnsFilter and group here
dim_date with date_key int (YYYYMMDD)Conformed calendar for every fact
surrogate key + natural key columnVersioning and source independence
conformed dimensionsSame dim_customer for every fact table
degenerate dimension (order_id in the fact)Identifier with no attributes of its own

Slowly changing dimensions

Type 1: overwriteCorrections; no history
Type 2: new row, valid_from/valid_to/is_currentFull history; facts point at the version in effect
Type 3: previous_value columnLimited before/after comparison
point-in-time join: valid_from <= t < valid_toLook up the right version when loading facts
tests: one current per key, no overlaps, no gapsSCD2 invariants

Fact types and modern shapes

transaction factPer event; sum freely
periodic snapshotPer entity per period; state at time t
accumulating snapshotPer process; milestone columns; lags
raw events immutable + typed event tablesRebuildable downstream
One Big Table as presentation layerBuilt from the star, not instead of it
model doc: grain, measures, keys, excludes, metric definitionWrite it with the DDL

Common pitfalls

  • Mixing grains in one fact table (order rows and line rows together); every sum is wrong.
  • Using the source system's id as the dimension key, making history impossible.
  • Overwriting dimension attributes (Type 1) that the business reports on over time; last year's numbers change.
  • Snowflaking dimensions into chains of small tables for no query benefit.
  • Letting each dashboard compute revenue its own way.
  • Storing money in floating point.
Quiz

Check your understanding

5 questions · 4 to pass · answers are explained as you go. Your best score is saved on this device only.

Progress and quiz scores are saved in this browser only. Back up or restore on the hub.

Was this lesson useful? Tell me what to improve →