🗄 SQL & Databases · Chapter 9
Normalisation vs denormalisation
Normalise so a fact is stored once. Denormalise so a question is answered once. Which you want depends entirely on whether you're writing or reading.
Study this lesson free in the app →
The basics
Normalisation is designing tables so that every fact is stored in exactly one place; denormalisation is deliberately copying facts into wide tables so questions can be answered without joins.
The problem it solves: if a driver's phone number is copied onto 40,000 ride rows, changing it means 40,000 updates — and if the job dies halfway, the data disagrees with itself and nothing tells you which copy is right. Normalised design makes that impossible: the number lives in drivers, and rides point to it by driver_id.
The mental model: your phone's contacts list versus printed wedding invitations. Contacts hold each person once — edit one entry and you're never confused. The invitations carry copies of addresses because at posting time you don't want 200 lookups. Databases that take constant small writes (a payments system, a booking app) want the contacts list. Warehouses that rebuild tables in bulk and scan millions of rows want the invitations.
Words you must know 5
- Normal forms (1NF, 2NF, 3NF)
- rules that remove repeated and misplaced data; 3NF is the practical target.
- Anomaly
- a bug caused by duplication: update, insert or delete.
- Fact table / dimension table
- events (rides) vs the things they describe (drivers, cities).
- Star schema
- one fact table joined to denormalised dimensions.
- SCD Type 2
- keeping a new dimension row per version so history doesn't rewrite itself.
Explained
A driver changes their phone number.
Normalised: one row updates in drivers. Done.
Denormalised: the number is copied onto 40,000 ride rows. You update 40,000 rows, and if the job dies halfway, some rides now say one number and some say another — and there is no way to tell which is right.
That's normalisation's whole argument: store each fact exactly once, so it can't disagree with itself.
The counter-argument is equally simple, and it's why warehouses ignore all of this.
The address book and the wedding invitations
Your address book holds each person once. Someone moves, you change one entry, and you're never confused about where they live. That's normalised — one fact, one place.
But the wedding invitations you printed have addresses written on them. Two hundred envelopes, each with a copy. If someone moves after printing, some envelopes are wrong.
Why copy at all? Because at posting time you don't want to look up two hundred addresses. The copy exists so that the reading is fast.
Normalised = the address book, optimised for change. Denormalised = the envelopes, optimised for use. Both are correct; they're solving different problems, and the whole discipline is knowing which problem you have.
The normal forms, practically
Textbooks list six. Three matter, and the third is where most real bugs live.
1NF — no repeating groups. One value per cell.
✗ ride_id | driver_vehicles
r1 | 'car, bike, scooter' ← can't query, can't index
✓ a separate row per vehicle2NF — no partial dependency on part of a composite key. If the key is (ride_id, vehicle_id), a column depending only on ride_id is misplaced.
3NF — no transitive dependency. A non-key column must not depend on another non-key column.
✗ rides(ride_id, driver_id, driver_phone, driver_city)
driver_phone depends on driver_id, not on ride_id
✓ rides(ride_id, driver_id) + drivers(driver_id, phone, city)The plain-English version, and it's enough:
Every non-key column describes the key, the whole key, and nothing but the key.
If a column in rides actually describes the driver rather than the ride, it belongs in drivers. That single test catches nearly everything the formal definitions do.
Watch an anomaly appear
Update, delete and insert anomalies are the three symptoms. They all come from the same cause: one fact stored in more than one place.
Why warehouses deliberately denormalise
Everything above assumes writes are frequent and reads are point lookups. In analytics both assumptions are false.
| | OLTP (normalised) | OLAP (denormalised) | | --- | --- | --- | | Writes | constant, small | bulk, append-only | | Reads | one row by key | scan millions, aggregate | | Cost of a join | small | large — shuffles across a cluster | | Cost of duplication | inconsistency | just storage, which is cheap |
A warehouse fact table rebuilt nightly has no update anomaly risk — nothing is updated in place. So the argument for normalisation largely evaporates, while the argument against joins gets stronger with every extra row.
The standard compromise is the star schema: a big fact table plus a handful of wide, denormalised dimension tables.
fact_rides(ride_id, driver_key, city_key, date_key, fare)
dim_driver(driver_key, driver_id, name, phone, city, vehicle_type, ...)One join instead of five. And a snowflake schema — where dimensions are themselves normalised into sub-tables — is usually a step backwards for exactly the reason this lesson is about: it trades cheap storage for expensive joins.
Where does this column belong?
A single table:
rides(ride_id, ride_date, fare, driver_id, driver_name,
driver_rating, city_id, city_name, city_timezone,
surge_multiplier, payment_method)The rating that changed history
A reporting table stores driver_rating directly on each ride, copied at query time from drivers:
SELECT r.ride_id, r.fare, d.rating
FROM rides r JOIN drivers d ON r.driver_id = d.driver_id;In March, an analyst reports: "rides by drivers rated below 3 stars in January: 1,240".
In April, the same query for January returns 890.
Nothing about January changed. But drivers' current ratings did — some improved above 3 stars, and the join picks up today's rating, not January's.
The report is not reproducible, and every historical analysis quietly rewrites itself as the dimension drifts.
The fix is a Type 2 dimension — keep a row per version with validity dates, and join on the date:
JOIN dim_driver d
ON r.driver_id = d.driver_id
AND r.ride_date >= d.valid_from
AND r.ride_date < d.valid_toNow January's report uses January's ratings, permanently.
The principle: a fact should record what was true when it happened. Joining a fact to a mutable dimension without a temporal condition means your history changes under you — and unlike most bugs, it produces numbers that were right yesterday and are right differently today.
Every non-key column describes the key, the whole key, and nothing but the key.
Normalise for writes (one fact, one place). Denormalise for reads (one question, one table).
Duplication costs consistency in OLTP and only storage in a warehouse.
Words to know
| Normalisation | Organising data so each fact is stored exactly once. |
|---|---|
| Denormalisation | Deliberately duplicating data to avoid joins at read time. |
| 1NF | One value per cell — no comma-separated lists or repeating groups. |
| 3NF | No non-key column depends on another non-key column. |
| Update anomaly | The same fact stored twice, updated once, now contradicting itself. |
| Delete anomaly | Deleting one row destroys an unrelated fact that was only stored there. |
| Star schema | A central fact table joined to wide, denormalised dimension tables. |
| Snowflake schema | A star whose dimensions are further normalised — usually more joins for little gain. |
| Surrogate key | A meaningless generated key for a dimension row, stable when natural keys change. |
| SCD Type 2 | A dimension keeping one row per version with validity dates, preserving history. |
In two minutes
- Normalise to store each fact once, so it can't disagree with itself — the right default for OLTP systems.
- 3NF in one line: every non-key column describes the key, the whole key, and nothing but the key.
- Duplication causes update, insert and delete anomalies; nothing errors, the data just becomes wrong.
- Warehouses denormalise on purpose: tables are rebuilt, not updated, and every join is a shuffle.
- Star schema: a narrow fact table plus wide, denormalised dimensions — not snowflaked further.
- Denormalising a mutable attribute rewrites history; changing attributes need SCD Type 2 with validity dates.
- Ask what the table is for before choosing — write-heavy system of record vs read-heavy analytics.
Test yourself
1. What is the plain-English test for 3NF?
- No duplicate rows
- Every non-key column describes the key, the whole key, and nothing but the key
- Every table has a primary key
- No NULLs allowed
Show the answer
Every non-key column describes the key, the whole key, and nothing but the key — If a column in rides describes the driver rather than the ride, it belongs in drivers. That test catches nearly everything.
2. Why do warehouses deliberately denormalise?
- Storage is expensive
- Tables are rebuilt rather than updated, so there's no update anomaly — and every join costs a shuffle
- SQL is easier
- It improves consistency
Show the answer
Tables are rebuilt rather than updated, so there's no update anomaly — and every join costs a shuffle — The argument for normalisation is consistency under updates. Append-only analytical tables don't have that problem.
3. You delete the only ride by a driver and lose their phone number. What is this called?
- Update anomaly
- Delete anomaly
- Insert anomaly
- Referential integrity
Show the answer
Delete anomaly — The fact only existed on that row. All three anomalies come from storing one fact in more than one place — or the wrong place.
4. A January report changes when re-run in April. Most likely cause?
- The January data changed
- A fact joined to a mutable dimension without validity dates, so it picks up today's values
- A caching bug
- Time zones
Show the answer
A fact joined to a mutable dimension without validity dates, so it picks up today's values — Facts should record what was true when they happened. Fix with a Type 2 dimension joined on the event date.
5. When is denormalising an attribute safe in a warehouse?
- Always
- When the attribute doesn't change over time — otherwise history rewrites itself
- Never
- Only for numeric columns
Show the answer
When the attribute doesn't change over time — otherwise history rewrites itself — Immutable attributes (signup cohort, country) can be copied freely. Changing ones belong in a dimension with validity dates.
6. Which column violates 1NF?
- fare stored as NUMERIC(10,2)
- ride_date stored as DATE
- driver_languages = 'Hindi, Kannada, English'
- city_id as a foreign key
Show the answer
driver_languages = 'Hindi, Kannada, English' — A comma-separated list is several values in one cell. You can't index it, join on it, or count Kannada speakers without string matching. It belongs in a driver_languages table, one row per language.
There are 9 questions, 3 puzzles and 7 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.
Start this lesson →