Trading duplication for read speed
Open in the interactive app: practice, quiz, mock interview →In one minute
- Denormalization stores the same fact in more than one place so reads avoid joins, aggregations, or cross-service calls.
- You trade write cost and consistency risk for read speed: every copy must be updated when the source changes, and copies can drift.
- Common forms: duplicated columns (author name on each post), precomputed aggregates (comment count), embedded documents, and read-optimized tables keyed by query.
- Do it for measured, hot read paths. Keep a single source of truth and a repair path for every copy.
How it works
Why normalize in the first place
A normalized schema stores each fact once, an idea that goes back to Codd's relational model, which treated redundancy and the inconsistency it invites as problems for the schema to remove (Codd, 1970). Updates touch one row, and anomalies (two addresses for the same customer) cannot happen. The price is paid at read time: a product page may join six tables, and an aggregate like "followers" scans an index on every view.
What denormalizing changes
You move work from read time to write time. Instead of joining posts to users for the author name, you copy author_name into posts. Instead of COUNT(*) over comments, you keep comment_count on the post. The read becomes a single-row lookup. Each write now has a fan-out: renaming a user updates every post they wrote, and adding a comment increments a counter.
Keeping copies in step
There are three main strategies, in decreasing order of consistency.
- Same transaction. Update the source and copies atomically. Strong, but only works inside one database, and hot counters create lock contention.
- Change-driven async update. Write the source, then propagate via triggers, change data capture, or events. Readers see stale copies for milliseconds to seconds.
- Periodic rebuild. Recompute copies in batch. Simple and self-healing, but staleness is measured in minutes or hours.
Most real systems combine an async path for freshness with a periodic reconciliation job that detects and repairs drift. Facebook's TAO stores every two-way graph edge as two denormalized rows on two shards; it writes them without a shared transaction, so a crash can leave one side missing, and an asynchronous job repairs these "hanging" edges (Bronson et al., USENIX ATC 2013).
In NoSQL stores
Key-value and wide-column stores (DynamoDB, Cassandra) offer no joins, so denormalization is the default design method. You list the access patterns before designing anything, then shape items so each pattern is one query on a table or index, with data duplicated as needed. Current DynamoDB guidance no longer treats one single table as the only right answer: it puts entities that are read together in one table and splits ones with independent access patterns (DynamoDB docs).
Down the stack
Layer 1The contract
A denormalized design promises: the source table is authoritative; each copy equals the source within a stated staleness budget (zero for same-transaction updates, seconds for change-driven updates, hours for batch rebuilds); and any drift is detected and repaired within a stated window. Readers of a copy get no guarantee that two copies agree with each other at the same instant. If you cannot state these three numbers for a field, the design is not finished.
Layer 2The mechanism
Same-transaction copies use application code or a trigger, for example an AFTER UPDATE OF name ON users FOR EACH ROW trigger that runs UPDATE posts SET author_name = NEW.name WHERE author_id = NEW.id. Change-driven copies usually read the write-ahead log: Debezium attaches to a PostgreSQL logical replication slot (pgoutput plugin) and emits events with before, after, and the source LSN (Debezium docs). The consumer applies them conditionally, UPDATE posts SET author_name = $1, author_ver = $2 WHERE author_id = $3 AND author_ver < $2, so replays and reordered events cannot roll a copy backwards. Deletes need the same care: keep a versioned tombstone for a while, or a delayed older update can recreate a row the source already removed (Azure Architecture Center). Counters use atomic increments (SET like_count = like_count + 1, or DynamoDB UpdateItem with ADD), and hot counters split into N sub-rows chosen at random and summed on read. An increment is atomic but not idempotent: a client that retries after a timeout may count twice. Where that matters, use a conditional write or record the event id with the increment (DynamoDB docs).
Layer 3The machine
In PostgreSQL every UPDATE writes a new tuple version, not an in-place change. If no indexed column changes and the page has free space, it is a heap-only tuple (HOT) update; otherwise every index on the table gets a new entry too (Postgres docs). Index a denormalized column and its updates can never be HOT. Each version also produces a WAL record, typically 100 to 300 bytes, and the commit waits for an fsync of the WAL, roughly 50 µs to 1 ms on SSDs. A single hot counter row therefore serializes on its row lock and on commit latency, capping it at a few thousand increments per second before group commit helps. Renaming a prolific user with 200,000 posts writes 200,000 new tuples, around 40 MB of heap plus WAL that every replica must replay. On the read side, a page of 50 posts with the author embedded is one index range scan touching 2 or 3 pages, while the join version may touch 50 more pages in users, each a random read if not cached.
Why it's built this way
- Reads often outnumber writes 100 to 1 or more (system-design-primer) and joins cost random page reads, so precomputing the answer on write is cheaper overall.
- Every copy must change when the source changes, so writes get amplified by the fan-out.
- Updating copies in the same transaction holds locks across many rows, so large fan-outs move to an asynchronous path.
- Asynchronous paths can lose, duplicate, or reorder events, so updates carry versions and a reconciliation job repairs drift.
- One row cannot absorb unlimited writes, so hot aggregates are sharded or batched.
Diagram
sequenceDiagram
participant App
participant Users as Users source
participant CDC as Change stream
participant Posts as Posts copy
App->>Users: UPDATE name = 'Ana B.'
Users-->>CDC: change event
CDC->>Posts: UPDATE author_name WHERE author_id = 42
App->>Posts: SELECT post (no join)
Note over Posts: stale until event appliedWorked example
A social feed renders 50 posts per page at 20,000 page views per second. Normalized, each page joins posts, users, and counts likes and comments: roughly 1 million row reads per second for counts alone, and P99 is 180 ms.
After denormalizing, each post row carries author_name, author_avatar, like_count, and comment_count. A page is one range read of 50 rows, and P99 drops to 15 ms. The write side changes:
- Likes arrive at 5,000 per second. Incrementing one row per like serializes on hot posts; a viral post can take 500 likes per second. The fix is sharded counters (10 sub-rows summed on read) or buffered increments flushed every second.
- A rename fans out to every post by that user. An average user has 300 posts, a prolific one 200,000. Renames are rare, so an async job batching 1,000 rows per transaction is fine.
- A nightly reconciliation recomputes counts for posts touched in the last day and fixes any that drifted.
In the wild
- Slack, 2022: a lookup sharded the wrong way turns into a scatter query. On 22 February a gradual Consul upgrade emptied part of the memcached tier. Group DM membership was cached by channel but stored in Vitess sharded by user, so every cache miss became a query sent to every shard. The database overloaded, could not refill the cache, and many users could not load Slack. The fix pointed the query at an existing table of the same membership sharded by channel, a copy keyed the way the query reads it (Slack).
- Facebook, 2013: denormalized graph edges with a repair job. TAO serves the social graph at about a billion reads and millions of writes per second. Each two-way relationship is stored as a forward and an inverse edge on different shards, plus cached per-type counts. The two writes are not atomic, so TAO accepts temporary drift and runs an asynchronous repair job, the same source-plus-reconciliation shape this lesson recommends (USENIX ATC 2013).
- Instacart, 2025: denormalized search documents cost 10x the writes. Instacart's search index in Elasticsearch held denormalized product documents, so billions of price and inventory changes became partial document rewrites, and fixing bad data could take days. Moving to a normalized model in Postgres cut the write workload by 10x and made search about twice as fast (Instacart).
Trade-offs
| Option | Strengths | Weaknesses | Choose when |
|---|---|---|---|
| Fully normalized | One source of truth, cheap writes | Joins and aggregates at read time | Write-heavy, or reads are already fast |
| Duplicated columns | Removes joins on hot reads | Fan-out on update, drift | Source rarely changes, read often |
| Precomputed aggregates | O(1) counts and sums | Hot-row contention, drift | Counts shown on every view |
| Separate read model | Shaped exactly for the query | Another store to run and rebuild | Read and write shapes differ a lot |
| Cache instead | No schema change | Invalidation, cold starts | Data is read-mostly and keyed simply |
Pitfalls in production
- Silent drift. A bug or failed event leaves copies wrong forever. Run reconciliation that compares copies to the source and alerts on mismatch rate.
- Hot counters. A single counter row becomes the write bottleneck. Shard the counter or batch increments.
- Unbounded fan-out. Updating a field copied into millions of rows locks tables or saturates the queue. Bound fan-out, or store a reference for high-cardinality cases.
- Forgotten copies. A new engineer updates the source and not the third copy added a year ago. Document copies next to the source, and route writes through one module.
- Out-of-order async updates. Two renames applied in the wrong order leave the old name. Carry a version and apply only newer versions.
Leadership lens
- Ask for the read and write ratio and the measured latency before approving a denormalization. "It might be slow" is not a reason.
- Require a named source of truth, a propagation path, a staleness budget, and a repair job for every duplicated field.
- Consider whether a cache or materialized view would get the same win with less ownership burden.
- Count the copies per entity. Each new copy adds a code path every future feature must remember.
Check yourself
Why do denormalized counters often need sharding while denormalized names do not?
Show answer
Counters are written on every event and concentrate writes on one row, while names change rarely, so their fan-out cost is occasional rather than continuous.
What does a periodic reconciliation job buy you if the async path already works?
Show answer
It bounds the lifetime of drift caused by bugs, lost events, or manual fixes, which the async path cannot detect by itself.
Why is denormalization the default in DynamoDB-style stores?
Show answer
They have no joins and charge per item read, so the only way to serve a query cheaply is to store data already shaped for it.
Sources and further study
Primary
- Codd, A Relational Model of Data for Large Shared Data Banks (1970): the paper that set up normal form and named redundancy as the thing to control.
- Bronson et al., TAO: Facebook's Distributed Data Store for the Social Graph (USENIX ATC 2013): denormalized inverse edges and counts at very large scale, with asynchronous repair.
Systems docs
- DynamoDB: first steps for modeling relational data: access patterns first, then single-table, multi-table or aggregate design.
- DynamoDB: atomic counters and conditional writes: why a retried increment overcounts and how conditions make writes idempotent.
- Debezium: PostgreSQL connector: the change event format and replication slot handling behind change-driven copies.
Books
- Designing Data-Intensive Applications (1st ed.), Martin Kleppmann, chapter 2 "Data Models and Query Languages" (normalization, one-to-many and many-to-one) and chapter 11 "Stream Processing" (section "Databases and Streams": keeping derived copies in sync with change data capture).
Other curricula
- donnemartin/system-design-primer: presents denormalization as trading write speed for read speed, mentions materialized views as the database-managed form, and warns that under heavy writes a denormalized schema can be slower than a normalized one.
- Azure Architecture Center: Index Table pattern: full, partial and no duplication, with versioned, idempotent updates and tombstones for the async path.
- roadmap.sh: System Design: includes Denormalization as its own node, alongside Materialized View and Index Table.
Watch
- Examples of Denormalization (Dr. Daniel Soper): a lecture walking through concrete denormalization techniques.
Free resources
- Wikipedia: Denormalization: background and the main techniques.
- DynamoDB: NoSQL design best practices: designing tables around access patterns.
- MongoDB: Data Modeling: when to embed and when to reference.
- Martin Fowler: CQRS: separating read models from write models, and the cost of doing so.