Skip to main content

Normalization vs Denormalization

Level 2: Level 2: Data Storage & Modelingmedium30 minnormalizationdenormalizationmodeling

Normalize by default for write integrity; denormalize only a named hot read path with a real read/write ratio, and use materialized views as the managed middle ground.

The most fundamental lever in schema design

Normalization and denormalization are the two ends of the single most fundamental lever in schema design: you are trading write integrity against read performance, and every schema sits somewhere on that line.

Normalization: each fact exactly once

Third normal form (3NF), the practical target, says every non-key column depends on the key, the whole key, and nothing but the key. A product's name and price live in one products row; an order_items row references that product by product_id rather than copying the name and price. The payoff is write integrity: change the product name in one place and every order that references it sees the new name, with zero risk of two rows disagreeing. Normalized schemas make writes cheap and correct, and they make update anomalies (the same fact stored in two rows that drift apart) structurally impossible.

The cost is joins on read. Joins are perfectly fine when they are indexed and bounded: an index on order_items.order_id and a primary-key lookup on products turns a 3-table join into a handful of B-tree seeks, and Postgres or MySQL will serve that in single-digit milliseconds even at hundreds of millions of rows. Joins fail to scale in two situations. First, when the join fan-out is large and unbounded (joining a user to all of their events across years). Second, and this is the one that actually forces the issue, when the tables live on different shards: a cross-shard join means a scatter-gather across the network, and that does not scale. Once your data is sharded, you must co-locate or denormalize.

Check yourself
A teammate opens a schema review with: 'Joins are slow, so I denormalized the whole schema up front for performance.' What is the strongest objection?

Denormalization: pay at write time, on purpose

Denormalization means deliberately storing a copy of a fact where it is read, to avoid a join or a cross-shard lookup on a hot read path. For a read-heavy order-history page you precompute a row that already contains the product name, the quantity, the line total, and the order status, so rendering the page is a single indexed range scan with no joins. The cost is symmetrical: you now have copies to keep in sync, so a product rename becomes a fan-out write that must touch every denormalized copy, and if you miss one you get an update anomaly. You have moved the pain from read time to write time, which is the right trade only when reads vastly outnumber writes.

Interview nuance: the strong answer never says "denormalize for performance" in the abstract. It names the specific query, the read/write ratio, and the scale trigger: "this order-history query runs 20k times per second, product data changes maybe once a day, so I denormalize the display fields into the order row and accept a rare backfill on rename."

The managed middle ground is a materialized view (or a summary table). You keep the source of truth normalized, and the database maintains a precomputed, denormalized copy for you, refreshing it on a schedule or incrementally. You get join-free reads without hand-writing fan-out logic, at the cost of some staleness. Daily revenue rollups, leaderboards, and dashboard aggregates are the classic use.

Schema
orders
  • order_id
  • user_id
  • status
order_items
  • order_id
  • product_id
  • qty
products
  • product_id
  • name
  • price
order_history_rows
  • order_id
  • user_id
  • statuscopy
  • product_namecopy
  • qtycopy
  • line_totalprecomputed
  • created_at
  • orders1-norder_itemsjoin on read: indexed, single-digit ms
  • products1-norder_itemseach fact exactly once
  • orders1-norder_history_rowsrefreshed as a materialized view
  • products1-norder_history_rowsrename = fan-out write to every copy
The lever end to end: the three normalized tables store each fact exactly once, so writes stay cheap and update anomalies are structurally impossible; order_history_rows is the deliberate read model, join-free for the query that runs 20k times per second, paying with a fan-out write (or a materialized view refresh) when a product changes.

Recap: normalize by default for write integrity, denormalize only for a specific hot read path with a real read/write ratio and scale trigger (especially to dodge cross-shard joins), and reach for materialized views when you want join-free reads without hand-maintaining the copies.

Check yourself
Your order-history page renders 20k times per second; product names change a few times a day. You want join-free reads but do not want to hand-write fan-out sync code. Which design fits?

Apply

Your turn

The task this lesson builds to.

Design the schema for an e-commerce order, line-items, and product catalog, then denormalize it for a read-heavy order-history page.

Think about

  1. When are joins fine, and when do they fail to scale?
  2. What is the cost of denormalization (update anomalies, fan-out writes)?
  3. How do materialized views offer a managed middle ground?

Practice

Make it stick

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

Design the data model for the Amazon-scale 'Your Orders' page where the orders service is sharded by customer_id across hundreds of nodes and must render a customer's last 50 orders in under 100 ms at p99, while the product catalog is a separate, globally replicated service. Show exactly where you refuse to join and what you denormalize instead.

Think about

  1. What would a per-line-item catalog call cost against a 100ms p99 budget?
  2. Which fields are frozen historical facts, safe to snapshot at order time?
  3. Which fields must stay live, and where do they live so render needs no cross-service call?