skip to content

What does it mean to denormalize a transactional database schema, and what do you gain and give up by doing it?

level: juniorimportance: must knowfreq 58%

answer

  1. deliberate redundancy for read speed
  2. three shapes: copied column, precomputed aggregate, summary table
  3. cost = drift, write amplification, hot-row contention
  4. index and cache before duplicating
  5. snapshot columns are not denormalization

basics

~20 s

Denormalizing means deliberately storing redundant data — a copied column, a precomputed count, a summary table — so reads avoid joins or aggregation. You gain read speed and simpler queries; you give up a single source of truth, so every write path must keep the copies in step or they drift.

solid answer

~50 s

A normalized schema stores each fact exactly once, so writes touch one row and the database cannot contradict itself. Denormalizing means knowingly breaking that: duplicating a column into a child table so a query need not join, storing `comment_count` on a post instead of counting rows, or maintaining a summary table for a heavy report. **Gain:** fewer joins and no aggregation on the read path, which turns a scan-and-group into a single indexed row read. Query text is simpler, and latency is more predictable under load. **Give up:** the copy can disagree with the source. Every writer — services, batch jobs, migrations, admin scripts — must update both, and a crash or a concurrent update between the two writes leaves them inconsistent. Writes get more expensive and can contend on the aggregate row. The database can no longer enforce the relationship with a key. So it is a deliberate, measured trade, applied to a specific hot query, with a documented sync mechanism and a way to detect drift.

code

sql · 10 lines
sql
-- normalized: cost grows with the number of comments
SELECT p.id, p.title, count(c.id) AS comments
FROM post p LEFT JOIN comment c ON c.post_id = p.id
WHERE p.feed_id = $1
GROUP BY p.id, p.title;

-- denormalized: constant cost, but comment_count must be maintained
ALTER TABLE post ADD COLUMN comment_count integer NOT NULL DEFAULT 0;

SELECT id, title, comment_count FROM post WHERE feed_id = $1;

go deeper

for a junior

Define it clearly, give one concrete example such as a stored comment count, and name both sides: faster reads, risk of the copy going stale.

for a middle

Add the write-side consequences — extra writes, contention on the parent row — and the alternatives you would try first, especially covering indexes.

for a senior

Lead with measurement and an explicit contract: which query it serves, what keeps it in sync, what staleness is acceptable, and what detects drift.

for a principal

Position it as adding a second source of truth, and discuss where that invariant is owned, how it is monitored, and under what evidence it would be removed again.

## What normalization was buying you In a normalized schema every fact lives in exactly one place. A customer's name sits in one row of `customer`; an order references it by `customer_id`. Because the fact is stored once, an update is one row write and it is impossible for two parts of the database to disagree. Foreign keys and unique constraints let the engine enforce the structure without any application help. The cost is that answering a question often means reassembling it: joins to pull related columns together, and aggregation to count or sum child rows. ## What denormalization is Denormalization is deliberately storing something more than once so a read does less work. In an OLTP schema it shows up in three recognisable shapes. **Duplicated columns.** Copying `customer_name` onto `order` so an order list does not join `customer`. Sometimes this is not redundancy at all but a **point-in-time snapshot** — the price and address on an invoice must reflect the moment of purchase, not today's customer record. That case is not denormalization; it is a different fact that happens to look similar, and it needs no synchronisation. **Precomputed aggregates.** A `comment_count` on `post`, a `balance` on `account`, an `item_total` on `order`. The value is derivable by counting or summing child rows, and is stored so the read is a single row fetch. **Read-optimised tables.** A summary table inside the same transactional database, refreshed on write or on a schedule, that a dashboard or a heavy list screen reads instead of grinding through base tables. ## The gains, stated precisely The win is on the read path: fewer joins, no group-by, fewer rows touched, and often a query that a single index can satisfy end to end. For a feed screen that renders comment counts for fifty posts, a stored counter turns fifty aggregate subqueries into one indexed scan. It also makes latency more *predictable*: aggregate cost grows with child-row count, while a stored value does not, so a post with 200,000 comments costs the same as one with three. ## The costs, stated precisely **Correctness.** Two representations of one fact can disagree. Drift comes from crashed jobs, forgotten write paths, bugs, race conditions between read-modify-write pairs, and manual data fixes. Once drifted, nothing self-heals — you need a reconciliation job to find and repair it. **Write amplification and contention.** Every insert of a comment now also updates the post row. That is an extra write, extra redo logging, and — worse — a hot row that concurrent writers must queue behind. A counter on a popular parent row can become the throughput ceiling of the whole feature. **Lost enforcement.** A foreign key can guarantee an order points at a real customer; nothing can guarantee the copied `customer_name` still matches that customer's current name. The invariant moves out of the engine and into code and jobs. **Coupling and maintenance.** Every future feature that writes the source must remember the copy. This is the cost that grows with team size and time, and the reason denormalization must be documented at the schema, not just remembered by whoever added it. ## The order of operations Denormalization is not the first tool. Before duplicating anything: confirm the query is actually hot with measurements, not intuition; check the plan; add or fix indexes — a covering index often removes the same work with no redundancy at all; consider a cache with an explicit expiry, which is redundancy with a built-in correction mechanism. Only when the join or aggregate genuinely dominates a proven hot path, and indexing cannot fix it, do you introduce a maintained copy. When you do, decide up front: what keeps it in sync (trigger, same-transaction application write, or asynchronous job), what staleness is acceptable, and what query detects drift. A denormalization without a reconciliation query is an outage waiting for a quiet week. ## How to say it in an interview Normalize first, measure, then denormalize the specific read that hurts — with a named sync mechanism, a documented staleness tolerance, and a drift check. Anyone who answers "denormalize for performance" without those three commitments is describing the upside only.

  • Is copying a customer's address onto an order row denormalization?
    Usually not. An invoice must record the address as it was at purchase time, so the order column is a distinct historical fact rather than a redundant copy of a current one. It needs no synchronisation and must not be updated when the customer moves. Denormalization only applies when the copy is supposed to stay equal to a live source.
  • What would you try before denormalizing a slow query?
    Measure to confirm the query is genuinely hot, then read its plan. Most cases are fixed by an index that supports the join or filter, or a covering index that answers the query without touching the table. Rewriting the query, or caching the result with an expiry, are both cheaper than a permanent schema redundancy because they do not create a second source of truth.

It is like writing a phone number on several sticky notes so you never have to open the address book. Lookups get instant, and the day the number changes you had better remember every note.

saying these in an interview costs you the question

  • Presenting denormalization as a default design style rather than a targeted fix for a measured hot query.
  • Ignoring the write side entirely — no mention of extra writes, hot-row contention, or which component keeps the copy in sync.
  • Assuming the copy stays correct because the application always updates both, forgetting backfills, admin scripts and concurrent writers.
  • Calling a point-in-time snapshot such as an invoiced price 'denormalized' and then trying to keep it synchronised with the current value.
  • Reaching for duplication before checking whether an index would remove the same work.

context