Part 4 · Databases & Data at Scale
Normalization vs Denormalization
Store each fact once and writes stay correct; copy facts on purpose and reads get fast. Normal forms in plain English, the anomalies they prevent, the denormalisation toolkit, and how to keep copies in sync.
On this page
The Problem
Your posts table stores the author's name in every row, so you do not need a join. A join is a query step that combines rows from two tables. Reads are fast. Then an author changes their name from "Asha K" to "Asha Kulkarni". Now 3,000 old posts show the old name, and 12 new ones show the new name.
Now think about the opposite problem. Your perfectly organised database needs seven joins to show one product page. At 10,000 views per second, this is too slow.
Normalisation means organising data so that each fact is stored only once (no duplicates). Denormalisation means copying data on purpose. They are two ends of one trade-off. One end gives correct data and simple writes. The other end gives fast reads.
The Core Idea
Think about your phone contacts.
Normalised: your friend's phone number is stored once, in their contact. Every WhatsApp group, calendar invite and note just points to that contact. When your friend changes their number, you update it in one place and everything is correct.
Denormalised: you wrote your friend's number in 20 different notes and on sticky notes around the house. You can find it quickly without opening your contacts. Reading is fast. But when the number changes, you must find and update all 20 copies. If you miss one, you will call the wrong number.
Normalise to keep data correct. Denormalise, with care, to make important reads fast.
How It Works
Normalisation: one fact, one place
Here is an unnormalised orders table. Customer details are repeated in every order row:
orders
┌────┬───────────┬───────────────┬───────────┬──────────────┬───────┐
│ id │ customer │ cust_email │ cust_city │ product │ price │
├────┼───────────┼───────────────┼───────────┼──────────────┼───────┤
│ 1 │ Asha │ asha@ex.com │ Pune │ Shoes │ 1299 │
│ 2 │ Asha │ asha@ex.com │ Pune │ Socks │ 199 │
│ 3 │ Ravi │ ravi@ex.com │ Delhi │ Shoes │ 1299 │
└────┴───────────┴───────────────┴───────────┴──────────────┴───────┘This causes three kinds of anomalies (wrong or lost data caused by the table design):
- Update anomaly: Asha moves to Mumbai. You must update every one of her orders. Miss one and the data disagrees with itself.
- Insert anomaly: you cannot store a new customer until they place an order.
- Delete anomaly: if you delete Ravi's only order, you also lose the fact that Ravi exists.
Normalised version:
customers products orders
┌────┬──────┬─────────┐ ┌────┬───────┬───────┐ ┌────┬─────────┬────────────┐
│ id │ name │ city │ │ id │ name │ price │ │ id │ cust_id │ product_id │
├────┼──────┼─────────┤ ├────┼───────┼───────┤ ├────┼─────────┼────────────┤
│ 1 │ Asha │ Pune │ │ 10 │ Shoes │ 1299 │ │ 1 │ 1 │ 10 │
│ 2 │ Ravi │ Delhi │ │ 11 │ Socks │ 199 │ │ 2 │ 1 │ 11 │
└────┴──────┴─────────┘ └────┴───────┴───────┘ │ 3 │ 2 │ 10 │
└────┴─────────┴────────────┘Each fact now lives in one place. If Asha's city changes, you change one row. Here cust_id and product_id are foreign keys (columns that point to a row in another table).
The normal forms, in plain English
A normal form is a set of rules for a table design. Each higher form adds a rule. A primary key is the column (or columns) that identifies each row.
- 1NF (First Normal Form): each cell holds one value. A list like "Shoes, Socks" in one cell is not allowed. Each row must also be unique.
- 2NF: every non-key column depends on the whole primary key, not just part of it. This matters for tables with composite keys (a primary key made of two or more columns).
- 3NF: non-key columns depend only on the key, not on other non-key columns. For example,
cust_citydepends on the customer, not on the order. So move it tocustomers. - BCNF (Boyce-Codd Normal Form): a slightly stricter version of 3NF.
A popular way to remember 3NF: every column should depend on "the key, the whole key, and nothing but the key". For most apps, 3NF is a good, practical goal.
An important exception: historical facts
Look at price again. Suppose the price of shoes changes tomorrow. Yesterday's order must still show what the customer actually paid. So an order line should store price_at_purchase. This is not bad duplication. It is a different fact: the price at that moment. Good modelling asks: is this the same fact, or a snapshot of it at one point in time? Addresses on invoices and names on tickets work the same way.
Denormalisation: duplicate on purpose
Normalised data needs joins to put the pieces back together. On one database this is usually fine. But at high scale, in NoSQL databases, or across shards (parts of a database split over machines), joins become slow or impossible. So we denormalise. This means we store data in the shape in which it is read.
These are common techniques:
1. Copy a few fields to avoid a join.
-- copy a rarely-changing field to skip a join on the feedALTER TABLE posts ADD COLUMN author_name TEXT;The feed can now show author names without joining the users table.
2. Precomputed counters.
-- precomputed counters instead of COUNT(*) on every page viewALTER TABLE posts ADD COLUMN comment_count INT NOT NULL DEFAULT 0, ADD COLUMN like_count INT NOT NULL DEFAULT 0;Do not run COUNT(*) over millions of comment rows on every page view. Instead, change the counter when a comment is added.
3. Embedded documents (NoSQL style). Put the related data inside the main record.
{ "orderId": 7, "customer": {"id": 1, "name": "Asha"}, "items": [...] }4. Materialised views. A materialised view is a saved copy of the result of a query. The database stores it like a table and you refresh it when you want:
CREATE MATERIALIZED VIEW daily_sales ASSELECT date(created_at) AS day, SUM(total) AS revenueFROM orders GROUP BY 1;REFRESH MATERIALIZED VIEW daily_sales; -- e.g., every few minutes5. Read models / CQRS. CQRS (Command Query Responsibility Segregation) means you use separate models for writing and for reading. You keep a normalised "write model". You also keep one or more denormalised "read models" built from it, often through events. Examples are a search index or a feed table for each user (Part 6).
Keeping copies in sync
Every copy is a promise that you must keep. These are your options:
- Update the copies in the same transaction (when they are in the same database). A transaction makes all changes succeed together or fail together (Lesson 21). This is the most correct option.
- Application logic ("when a user renames, also update their posts"). It is easy to forget this in one of the code paths.
- Database triggers. A trigger is code that the database runs by itself when a row changes. It works automatically, but it is hidden and can surprise people.
- Events / Change Data Capture (CDC): send a "user renamed" event (or read the database change log) and let a worker update the copies. This is reliable at scale. But the copies are briefly stale (out of date for a short time). This is called eventual consistency.
- Accept staleness for copies that matter little ("author name on old posts may be a few minutes late").
Also ask: how often does this field change? Copying a user's name (it changes rarely) is low risk. Copying their follower count (it changes all the time) needs a clear update plan.
- 1Same transactionupdate the original and every copy together (same database only)
- 2Application code“on rename, also update posts”; easy to forget in one code path
- 3Database triggerautomatic, but hidden and surprising
- 4Events / CDCa worker updates copies from the change log; reliable at scale, briefly stale
- 5Accept stalenessfine for low-value copies like an author name on old posts
Where each approach fits
| Situation | Lean towards |
|---|---|
| Core business data (orders, payments, accounts) | Normalised: correctness first |
| Read-heavy pages at high traffic (feeds, product pages) | Denormalised read models or caches |
| NoSQL / wide-column databases | Denormalised, modelled per query |
| Analytics warehouses | Denormalised star schemas (a central "facts" table with the numbers, plus "dimension" tables with the details around it) |
| Early-stage product | Normalised first; denormalise specific hot paths when measurements say so |
- +One source of truth, so no update anomalies
- +Flexible queries, smaller storage
- −More joins for complex reads
- +Reads shaped exactly like the screen
- +No joins; works across shards and in NoSQL
- −Slower, more complex writes
- −Copies can drift out of sync
Trade-offs
Normalisation
- ✅ One source of truth, no update anomalies, flexible queries, smaller storage.
- ❌ More joins. They can be slow for complex reads at very high scale.
Denormalisation
- ✅ Fast, simple reads that match what the screen needs.
- ❌ Slower and more complex writes, a risk that copies disagree, more storage, and more code to maintain.
Common mistake: denormalising before you measure. Joins on well-indexed tables are fast. Start normalised. Add caching (Lessons 15–16). Denormalise only the paths that you have proven to be slow.
In the Real World
Social feeds. Platforms like Twitter/X and Instagram store denormalised feeds. A feed is a list of post IDs for each user, often kept in Redis. So opening the app is one fast read. The app does not have to join "everyone I follow" with "all their posts" each time. We design this in the news-feed case study (Lesson 64).
Like and view counts. Large platforms show counts like "1.2M views" from precomputed, denormalised counters. These counters are updated asynchronously (later, in the background). That is why counts sometimes lag or jump. It is a deliberate trade: less exact, but faster.
E-commerce invoices. Online stores copy the product name, price and address into each order at checkout. So invoices stay correct even if the product is renamed or repriced, or the customer moves. This looks like denormalisation, but it really is keeping history.
Data warehouses. Analytics tools like BigQuery, Snowflake and Redshift commonly use star schemas: a central "sales" fact table surrounded by dimension tables like date, product and region. They are built for fast aggregation (sums, counts, averages), not for transactional updates.
Interview Questions
Q1What problems does normalisation solve?
It solves update, insert and delete anomalies. Each fact is stored once. So a change touches only one row. You can store an entity without an unrelated record. And deleting one record does not erase a different fact.
Q2When would you denormalise?
I would do it when measurements show that a read path is too slow, or impossible, without joins. Examples: busy feeds or product pages, counts recomputed on every view, data spread across shards, or NoSQL stores modelled per query. I would also need a clear plan to keep the copies in sync.
Q3Is storing price in the order row a violation of normalisation?
No. The price at purchase is a different fact from the product's current price. It is history. Invoices, shipping addresses and names on tickets must keep the value that was true when the event happened.
Q4How do you keep a denormalised counter accurate?
Increase it atomically (in one step) in the same transaction as the insert: UPDATE posts SET comment_count = comment_count + 1. Or update it later from events, and from time to time compare it with the true count and fix it. At very high rates, batch the updates or split the counter into several parts (shard it).
Q5What's a materialised view?
It is a saved query result that the database refreshes on request or on a schedule. It gives fast reads of expensive aggregations. The cost is that the data is stale between refreshes, and the refresh itself takes work.
Key Takeaways
- Normalisation stores each fact once. It prevents update, insert and delete anomalies. Aim for 3NF for core data.
- Denormalisation duplicates data on purpose (copied fields, counters, embedded documents, materialised views, read models) to make reads fast.
- Every copy needs a sync plan: same transaction, application code, triggers, or events/CDC. Decide how stale each copy may be.
- Snapshots of history (price at purchase, address on an invoice) are correct modelling, not bad duplication.
- Start normalised, measure, then denormalise specific hot paths.
Further Reading
- The System Design Primer on GitHub (section "Denormalization")
- Designing Data-Intensive Applications by Martin Kleppmann (chapter 2, and Part III on derived data)
- The PostgreSQL documentation on materialised views
- Martin Fowler's article "CQRS"
Published by Jainik · Last updated · How these lessons are made
Keep learning
Part of the study guide
Related series