Data modelling: normalisation, star schemas and SCDs
Learn how to design tables for apps (normalisation) and for analysis (star schemas with fact and dimension tables), and how slowly changing dimensions keep history correct. A staple of data engineering interviews.
Data modelling means deciding which tables you have, what one row in each table means, and how the tables connect. It sounds dull but it decides whether your queries are simple or painful, and whether your numbers are right.
There are two main styles, and they solve opposite problems. Normalised models (used by application databases) avoid repeating data so that updates are safe. Dimensional models (used by warehouses) group data so that reports are easy and fast. A data engineer moves data from the first kind to the second.
Normalisation: store each fact once
Imagine Brew & Bean stored everything in one spreadsheet-like table, one row per item sold:
One big table
| order_id | customer_name | customer_phone | item_name | category | price | qty |
|---|---|---|---|---|---|---|
| 1 | Asha | 98450 11111 | Cappuccino | coffee | 180 | 2 |
| 1 | Asha | 98450 11111 | Butter Croissant | bakery | 150 | 1 |
| 2 | Ravi | 98450 22222 | Cappuccino | coffee | 180 | 1 |
Problems show up quickly. Asha's phone number is stored twice; if she changes it you must update every row, and if you miss one the data disagrees with itself (an update anomaly). If you stop selling croissants and delete all their rows, you also lose the fact that croissants cost ₹150 (a delete anomaly). You cannot add a new menu item until someone orders it (an insert anomaly).
Normalisation fixes this by splitting the table so each fact lives in exactly one place.
The first three normal forms
| Normal form | Rule in plain words | What to do with our table |
|---|---|---|
| 1NF | Each cell holds one value; no repeating groups | Don't put "Cappuccino, Croissant" in one cell. One row per item in an order |
| 2NF | Every non-key column depends on the whole key, not part of it | With key (order_id, item_name), customer_name depends only on order_id, so move it to an orders table. price depends only on the item, so move it to an items table |
| 3NF | Non-key columns depend only on the key, not on other non-key columns | In customers, if you stored city and pin code, keep a separate table if city depends on pin code |
A useful summary is: *every column depends on the key, the whole key, and nothing but the key*. After normalising, Brew & Bean has four small tables:
CREATE TABLE customers (
customer_id INTEGER PRIMARY KEY,
name TEXT NOT NULL,
phone TEXT NOT NULL
);
CREATE TABLE items (
item_id TEXT PRIMARY KEY, -- 'cappuccino'
name TEXT NOT NULL,
category TEXT NOT NULL,
price INTEGER NOT NULL -- rupees
);
CREATE TABLE orders (
order_id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL REFERENCES customers(customer_id),
created_at TEXT NOT NULL
);
CREATE TABLE order_items (
order_id INTEGER NOT NULL REFERENCES orders(order_id),
item_id TEXT NOT NULL REFERENCES items(item_id),
qty INTEGER NOT NULL,
PRIMARY KEY (order_id, item_id)
);Each fact is stored once. Phone number lives only in customers; price only in items.
This design is excellent for an app that takes orders: writes are small and safe. But to answer "revenue by category by month" you must join four tables. On a few thousand rows that is fine. On billions of rows, many joins get slow and confusing for analysts. That is where dimensional modelling comes in.
Dimensional modelling and the star schema
A star schema has one central fact table surrounded by dimension tables, so a diagram looks like a star.
- A fact table records events or measurements: one row per order line, per payment, per click. It holds numbers you add up (quantity, amount) and keys that point to dimensions. - A dimension table describes the who, what, when and where: customers, items, dates, stores. It holds the descriptive text you filter and group by.
Brew & Bean star schema
| Table | Type | One row means | Example columns |
|---|---|---|---|
| fact_order_items | Fact | One item line on one order | order_id, item_key, date_key, qty, line_total |
| dim_item | Dimension | One item (version) | item_key, item_id, item_name, category, price |
| dim_date | Dimension | One calendar day | date_key, full_date, day_name, month, year |
| dim_customer | Dimension | One customer | customer_key, name, city |
The most important idea is grain: the exact meaning of one row in the fact table. Decide and write it down first: "one row per item on an order". If grain is unclear, you will double count or miss rows. Everything else follows from it.
CREATE TABLE dim_date (
date_key INTEGER PRIMARY KEY, -- 20261015
full_date TEXT,
day_name TEXT,
month INTEGER,
year INTEGER
);
CREATE TABLE dim_item (
item_key INTEGER PRIMARY KEY, -- surrogate key, no business meaning
item_id TEXT, -- business key: 'cappuccino'
item_name TEXT,
category TEXT,
price INTEGER,
valid_from TEXT NOT NULL,
valid_to TEXT,
is_current INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE fact_order_items (
order_id INTEGER,
item_key INTEGER REFERENCES dim_item(item_key),
date_key INTEGER REFERENCES dim_date(date_key),
qty INTEGER,
line_total INTEGER
);dim_item has extra columns (valid_from, valid_to, is_current) that we explain in the SCD section below.
Now the earlier question needs just two joins to dimensions and one GROUP BY:
SELECT i.category, d.month, SUM(f.line_total) AS revenue
FROM fact_order_items AS f
JOIN dim_item AS i ON i.item_key = f.item_key
JOIN dim_date AS d ON d.date_key = f.date_key
GROUP BY i.category, d.month
ORDER BY i.category, d.month;Every report looks like this: join the fact to the dimensions you need, then group and sum.
A surrogate key (item_key) is a meaningless number the warehouse creates. A business key (item_id) comes from the source system. Using surrogate keys lets you keep several versions of the same item, which you need for slowly changing dimensions.
Two styles compared
| Question | Normalised (OLTP) | Star schema (OLAP) |
|---|---|---|
| Main goal | Safe, fast small writes | Fast, simple analysis |
| Redundancy | Avoided | Accepted (category repeated on each item row) |
| Typical query | Fetch one order | Sum revenue over millions of rows |
| Joins | Many | Few: fact to dimensions |
| Where used | App database | Warehouse or lakehouse |
Slowly changing dimensions (SCDs)
Dimensions change, slowly. A customer moves city. A menu item's price rises. A product moves to a different category. What should last year's sales report show? The price at that time, or today's? Different answers need different handling.
SCD types
| Type | What it does | History kept? | Use when |
|---|---|---|---|
| Type 0 | Never changes the value | Original only | Values that are fixed, such as date of birth |
| Type 1 | Overwrite the old value | No | Corrections, or when history doesn't matter (fixing a spelling mistake) |
| Type 2 | Add a new row for the new version; close the old row | Yes, full history | You need to report as things were at the time |
| Type 3 | Add a column for the previous value | Only one step back | Rarely; you only care about "before and after" once |
Type 2 is the one interviewers ask about. Suppose the cappuccino price rises from ₹180 to ₹200 on 1 October 2026. We close the old row and insert a new one. Old facts still point to the old item_key (price 180) and new facts point to the new one (price 200).
BEGIN;
-- 1. close the current version
UPDATE dim_item
SET valid_to = '2026-10-01', is_current = 0
WHERE item_id = 'cappuccino' AND is_current = 1;
-- 2. insert the new version
INSERT INTO dim_item (item_id, item_name, category, price, valid_from, valid_to, is_current)
VALUES ('cappuccino', 'Cappuccino', 'coffee', 200, '2026-10-01', NULL, 1);
COMMIT;Both statements are inside one transaction, so a failure never leaves the table half-updated.
item_key | item_id | item_name | category | price | valid_from | valid_to | is_current
1 | cappuccino | Cappuccino | coffee | 180 | 2026-01-01 | 2026-10-01 | 0
2 | croissant | Butter Croissant | bakery | 150 | 2026-01-01 | None | 1
3 | cappuccino | Cappuccino | coffee | 200 | 2026-10-01 | None | 1When loading a new fact row, look up the dimension row whose validity covers the order date (or just the current row for today's data) and store its surrogate key. Tools such as dbt call this feature snapshots; you will meet it in *Transformations with dbt*.
Overwriting rewrites the past
Without Type 2, a price change silently rewrites history: last month's revenue per item would be recalculated with today's price, and old reports would no longer match what the owner saw earlier. Decide per column whether history matters.
Other terms you will hear
| Term | Meaning |
|---|---|
| Snowflake schema | A star where dimensions are themselves normalised into sub-tables (item to category). Saves space, adds joins |
| Wide / denormalised table | Everything pre-joined into one big table. Simple to query; common in modern columnar warehouses |
| Data vault | A modelling style with hubs, links and satellites for auditability in big organisations; know the name |
| Conformed dimension | A dimension (like dim_date) shared by several fact tables so reports can be combined |
How this comes up in interviews
Common questions
- "Explain 1NF, 2NF and 3NF with an example." (Use a table like the order example above.)
- "Design a star schema for [food delivery / UPI payments / an e-commerce store]." (State the grain first, then facts, then dimensions.)
- "What is the difference between a fact and a dimension table?"
- "What is an SCD Type 2 and how do you implement it?"
- "Why denormalise in a warehouse when we normalise in applications?"
You are a senior data modeller. I'm designing a star schema for [business, e.g. a food-delivery app].
Grain of the fact table: [one row per ...]
Fact table columns: [list]
Dimensions: [list with columns]
Check: (1) is the grain clear and consistent with the columns, (2) are any columns in the wrong table, (3) which dimensions need SCD Type 2 and why, (4) what questions would be hard to answer with this model. Be specific.What to expect: Good answers challenge the grain and point out mixed-grain columns (such as an order total stored on each line), then suggest which attributes need history.
Try it: Model UPI payments
A payments company records UPI transactions: transaction id, payer, merchant, merchant category, amount in ₹, status, time. Design a star schema. Write: the grain, the fact table columns, at least three dimensions with columns, and which dimension attribute you would treat as SCD Type 2.
Try it: Spot the grain mistake
A fact table has one row per order line, with columns order_id, item_key, qty, line_total, order_total. A colleague runs SUM(order_total) by day. What goes wrong and how do you fix it?
Key takeaways
- Normalisation stores each fact once (1NF, 2NF, 3NF) and suits application databases.
- A star schema has a fact table (events and numbers) surrounded by dimension tables (descriptions) and suits analysis.
- Decide the grain, what one fact row means, before anything else.
- Surrogate keys let you keep several versions of a dimension row.
- SCD Type 1 overwrites, Type 2 keeps history with new rows (valid_from, valid_to, is_current), Type 3 keeps one previous value.
- Overwriting a dimension changes past reports; choose per attribute.
Check yourself
Finishing the quiz marks the lesson as done.
1. In a customers table, you store customer_id, name and also city_name and pin_code, where city_name is determined by pin_code. Which normal form is violated?
2. What is the grain of a fact table?
3. A product's category changes from 'snacks' to 'bakery'. Finance needs last year's reports to keep showing 'snacks'. Which SCD type fits?
4. Why do warehouses accept repeating data (denormalised dimensions) that application databases avoid?
5. What is the purpose of a surrogate key in a dimension table?
Saved on this device. Create a free account to keep it on every device.
Practise this with Sam
A real mock interview, out loud, with an honest scorecard. Free to start.