NO SQL Lesson 26 – Denormalization | Dataplexa
Data Modeling & Design · Lesson 26

Denormalization

Every database textbook teaches you to normalize — eliminate redundancy, store each fact once, keep your schema clean. Then you try to scale to 100 million users and discover that normalization is a read-time tax you can no longer afford. Denormalization is not laziness or bad practice. At scale, it is the deliberate engineering decision that keeps reads fast, servers cheap, and users happy.

What Normalization Costs at Scale

In a fully normalized relational schema, data is stored exactly once. To assemble a meaningful response, the database executes JOINs at query time — combining rows from multiple tables on every single request. At small scale, JOINs are essentially free. At large scale, they become the bottleneck.

The cost of a JOIN at scale — a concrete example:

❌ Normalised — JOINs at query time

-- User feed: 500M users, 10B posts
SELECT p.body, u.name, u.avatar
FROM posts p
JOIN users u ON p.user_id = u.id
WHERE p.feed_id = ?
ORDER BY p.created_at DESC
LIMIT 20;

-- At 10B rows: index lookup on posts,
-- then 20 random seeks into users table.
-- Query time: 180ms under load

✅ Denormalised — data pre-joined at write time

// posts document already has author fields
{"{"}
  body: "Just shipped v2.0...",
  author_name: "Sarah Chen",
  author_avatar: "cdn.../sarah.jpg",
  created_at: ISODate("2025-01-15")
{"}"}

-- No JOIN needed. One collection read.
-- Query time: 3ms under load

The trade-off: if Sarah updates her display name, the normalised schema updates one row. The denormalised schema must update every post document Sarah ever wrote — potentially millions. You are trading write complexity for read speed. At read-heavy scale (most consumer apps read 10–100× more than they write), that trade is almost always worth it.

The Four Denormalization Techniques

Denormalization is not one thing — it is a family of techniques. Each solves a different performance problem.

Technique 1

Duplicate fields for read performance

Copy fields from a referenced document into the parent to avoid a second query. The referenced document remains the source of truth. The copy is a read-optimised snapshot. Used when the copied fields rarely change and are needed on every parent read.

Post stores author_name + author_avatar copied from the User document.

Technique 2

Pre-computed aggregates

Store COUNT, SUM, or AVG results directly in the document instead of computing them at query time. A product document stores review_count: 4821 rather than counting the reviews collection on every product page load. The aggregate is incremented on every write.

Product stores review_count and avg_rating instead of COUNT(reviews).

Technique 3

Duplicate tables for different access patterns

In Cassandra and DynamoDB, create multiple tables holding the same data structured around different query shapes. A trip exists in trips_by_driver and trips_by_city_date. Both are written on every trip completion. Each serves one access pattern perfectly.

Same trip data duplicated in three Cassandra tables for three query shapes.

Technique 4

Materialised views

Pre-compute the result of an expensive aggregation query and store it as a document that is refreshed on a schedule or on write. The read path hits the materialised view — a single document read — instead of running the aggregation pipeline every time.

Daily sales summary stored as one document, refreshed every hour by a background job.

Hands-on — Denormalizing a Product Catalogue

The scenario: You are the backend engineer for a marketplace with 5 million products and 200 million reviews. The product detail page is the most visited page on the site — 80 million page views per day. Every page load was running a COUNT and AVG aggregation against the reviews collection to display the star rating and review count. At peak, the reviews collection aggregation is taking 400ms and hammering the database. You are implementing pre-computed aggregates to fix it.

// BEFORE: product document with no denormalization
// Every page load runs: db.reviews.aggregate([{$match: {product_id}},
//   {$group: {_id: null, avg: {$avg: "$rating"}, count: {$sum: 1}}}])
// At 200M reviews — 400ms per page load

db.products.insertOne({
  _id:      "prod_441",
  name:     "Mechanical Keyboard TKL",
  price:    79.99,
  category: "Electronics"
  // No review stats — computed at query time (SLOW)
});
{ acknowledged: true, insertedId: 'prod_441' }
// Page load time: 400ms (aggregation on 200M reviews) ❌
The aggregation tax — why it kills page load time

Running COUNT and AVG on 200 million reviews on every product page load means scanning (at minimum) all reviews for that product on every request. Even with an index on product_id, returning from an aggregation pipeline is orders of magnitude slower than reading a single field from a single document.

The scenario continues: You redesign the product document to store pre-computed aggregates. The review stats are written to the product document every time a review is submitted — one extra write per review in exchange for eliminating the aggregation on every page load.

// AFTER: denormalized product document with pre-computed review stats
db.products.updateOne(
  { _id: "prod_441" },
  { $set: {
      // Pre-computed aggregates — updated on every new review
      "review_stats.count":      4821,
      "review_stats.avg_rating": 4.3,
      "review_stats.dist": {    // rating distribution for histogram
        "5": 2841,
        "4": 1102,
        "3": 512,
        "2": 244,
        "1": 122
      }
    }
  }
);
{ acknowledged: true, matchedCount: 1, modifiedCount: 1 }
// Page load time: 2ms (single document read) ✓
"review_stats.count": 4821

The product document now carries its own aggregate. The page load reads one product document and gets the star rating, count, and histogram in the same response. Zero aggregation. Zero second query. 200× faster. The cost: every time someone submits a review, the write path must also update these three fields on the product.

"review_stats.dist": {"{"} "5": 2841, "4": 1102, ... {"}"}

The rating distribution histogram is also pre-computed — normally this would be a GROUP BY rating aggregation. Storing the distribution as a nested object means the product page can render the star histogram instantly. This is denormalization of an aggregation result — pre-computing what would otherwise be computed on every read.

The scenario continues: You now write the review submission handler. Every new review must update the reviews collection AND atomically update the pre-computed stats on the product document. You use MongoDB's $inc and $set operators to do both in one write path — no aggregation query needed to recalculate the average.

from pymongo import MongoClient
db = MongoClient()["marketplace"]

def submit_review(product_id, user_id, rating, body):
    # Step 1: insert the review document
    db.reviews.insert_one({
        "product_id": product_id,
        "user_id":    user_id,
        "rating":     rating,
        "body":       body,
        "created_at": datetime.utcnow()
    })

    # Step 2: update pre-computed stats on the product atomically
    # Running count and sum allow us to recompute avg without a full scan
    db.products.update_one(
        { "_id": product_id },
        {
            "$inc": {
                "review_stats.count":         1,
                "review_stats.rating_sum":    rating,    # track sum for avg
                f"review_stats.dist.{rating}": 1         # histogram bucket
            }
        }
    )

    # Step 3: recompute avg from stored sum + count (no aggregation needed)
    product = db.products.find_one(
        { "_id": product_id },
        { "review_stats.count": 1, "review_stats.rating_sum": 1 }
    )
    stats = product["review_stats"]
    new_avg = round(stats["rating_sum"] / stats["count"], 1)
    db.products.update_one(
        { "_id": product_id },
        { "$set": { "review_stats.avg_rating": new_avg } }
    )
Review submitted for prod_441 by user_7291 — rating: 5
  reviews.insert_one:           1.1ms
  products.update_one ($inc):   0.9ms
  products.find_one (stats):    0.7ms
  products.update_one (avg):    0.8ms
Total write path: 3.5ms — product page load remains: 2ms ✓
"review_stats.rating_sum" — the running sum trick

Storing the sum of all ratings alongside the count means you can recompute the average at any time as sum / count — with no aggregation query against the reviews collection. This is the key insight of pre-computed aggregates: never store just the derived value, store enough raw data to update it incrementally without a full scan.

$inc for atomic increments

MongoDB's $inc operator increments a field atomically at the database level — no read-modify-write cycle in application code. Under high concurrency (hundreds of reviews submitted per second), using $inc instead of a fetch-then-update pattern prevents lost updates where two simultaneous review submissions overwrite each other's count increment.

Hands-on — Denormalizing a Social Feed

The scenario: You are a senior engineer at a social platform. The user feed endpoint must return the 20 most recent posts from accounts a user follows, including each post's author name, avatar, and like count. The normalised approach requires: a JOIN on followers, a JOIN on users, and a COUNT on likes — three operations on some of the largest tables in the system. At 50 million active users each loading their feed 10 times a day, this is 500 million feed loads per day. You are redesigning the feed using a denormalised fan-out-on-write model.

def publish_post(author_id, body, db):
    # Step 1: write the canonical post document
    post = {
        "_id":         str(uuid.uuid4()),
        "author_id":   author_id,
        "body":        body,
        "created_at":  datetime.utcnow(),
        "like_count":  0
    }
    db.posts.insert_one(post)

    # Step 2: fetch the author's followers
    author    = db.users.find_one({"_id": author_id})
    followers = db.followers.find({"following_id": author_id},
                                  {"follower_id": 1})

    # Step 3: fan-out — write a denormalised feed entry to every follower
    # Each feed entry is a self-contained read — no JOIN needed at read time
    feed_entries = [{
        "user_id":     f["follower_id"],        # whose feed this is
        "post_id":     post["_id"],
        "author_name": author["display_name"],  # denormalised snapshot
        "author_avatar": author["avatar_url"],  # denormalised snapshot
        "body":        body,                    # denormalised copy
        "created_at":  post["created_at"],
        "like_count":  0
    } for f in followers]

    if feed_entries:
        db.user_feeds.insert_many(feed_entries)
Post published by author_id: usr_882
  Canonical post written:             1.2ms
  Followers fetched: 2,400
  Feed entries fan-out (bulk insert): 18.4ms
  Total write path: ~20ms

Feed read for any follower:
  db.user_feeds.find({user_id}).sort({created_at:-1}).limit(20)
  Query time: 1.8ms (single collection, indexed) ✓
Fan-out on write — the read-optimised feed model

When someone with 2,400 followers publishes a post, you write 2,400 feed entry documents — one per follower. The write is heavier (20ms instead of 1ms). But each of those 2,400 followers now loads their feed in 1.8ms with zero JOINs. Twitter, Instagram, and Facebook all use variants of this pattern. The write cost is paid once; the read savings are multiplied across every feed load.

The celebrity problem

A user with 50 million followers publishing one post would fan-out to 50 million documents — a write that takes minutes and floods the database. The real-world solution (used by Twitter) is a hybrid: fan-out on write for regular users, fan-out on read for celebrities above a follower threshold. This lesson covers the core pattern; hybrid approaches are a topic for Lesson 31 (Consistency vs Availability).

The Denormalization Trade-off Map

Technique Read benefit Write cost Consistency risk When to use
Duplicate fields Eliminates one JOIN / second query Must update copies when source changes Copies may lag source briefly Fields rarely change (name, avatar)
Pre-computed aggregates Eliminates COUNT/AVG aggregation $inc on every write that affects the aggregate Count/avg lags by one event under race condition Aggregates displayed on high-traffic pages
Duplicate tables Each table serves one query perfectly N writes per event (one per table) Tables can diverge if a write partially fails Cassandra / DynamoDB multi-access-pattern
Materialised views Complex aggregation → single doc read Background job to refresh periodically Data is stale by refresh interval Dashboards, reports, tolerable staleness
Fan-out on write Feed reads require zero JOINs Write multiplied by follower count Celebrity accounts cause write storms Social feeds, activity streams

Managing Consistency — Keeping Denormalised Data in Sync

The hardest part of denormalization is keeping copies consistent when the source data changes. Three patterns handle this in production:

Synchronous update in the same write path

When the source document changes, update all copies in the same application request. Simple, strongly consistent. Works when the fan-out is small (under a few hundred copies). Gets slow when there are thousands of copies to update.

Asynchronous via event queue

The write updates the source document and publishes a change event to a queue (Kafka, SQS). Background workers consume the event and update all copies. Eventually consistent — copies lag by seconds. Scales to millions of copies. Used by almost every large platform.

Accept controlled staleness

For some fields — like a display name or an avatar — brief staleness is acceptable. Old posts showing "Sarah Chen" for a few seconds after a name change is not a business problem. Define a staleness SLA and document it. Not everything needs to be in sync immediately.

Teacher's Note

Denormalization without a clear consistency plan is how you end up with a product that shows a user's bio from 2019 on posts they wrote last week. Before you denormalize any field, write down: what triggers an update to the copy, how the update is propagated, and what the acceptable staleness window is. If you cannot answer all three, you are not ready to denormalize that field yet.

Practice Questions — You're the Engineer

Scenario:

Your social platform's feed endpoint was running three JOINs per request and taking 600ms. You redesign the write path so that when a user publishes a post, a denormalised feed entry document is immediately written to every follower's feed collection. The feed read becomes a single collection query with no JOINs, taking 2ms. What is this write-side denormalization pattern called?


Scenario:

Your product page stores review_stats.count as a pre-computed aggregate. Under high traffic, hundreds of reviews are being submitted simultaneously. A junior developer wrote the update logic as: fetch the product, add 1 to the count in Python, then write the new value back. Under load you see the count drifting — it is lower than the actual number of reviews. You explain there is a single MongoDB operator that performs the increment atomically at the database level. What is it?


Scenario:

Your product document stores review_stats.avg_rating: 4.3 and review_stats.count: 4821. A new review comes in with a rating of 5. To update the average without running an aggregation over all 4,821 reviews, you need one additional field you stored alongside count. You use this field divided by count to recompute the average instantly. What is this field called in the lesson's example?


Quiz — Denormalization in Production

Scenario:

Your platform embeds a denormalised author bio snippet in every post document. Author Sarah has written 2 million posts. She updates her bio. Updating the bio snapshot synchronously in the same request would require an updateMany touching 2 million documents — taking 40 seconds and blocking the API response. What is the correct production pattern for propagating this update?

Scenario:

Your e-commerce site serves 80 million product page views per day. Every page shows a star rating and review count. Currently the app runs a MongoDB aggregation pipeline against 200 million reviews on every page load — taking 400ms and making the reviews collection a permanent bottleneck. What is the correct denormalization strategy to fix this without rebuilding the entire data model?

Scenario:

You have deployed fan-out on write for your social feed. It works perfectly for 99% of users who have under 5,000 followers — write latency is 20ms. But your platform just onboarded a celebrity with 50 million followers. The first time she posts, your database write queue backs up for 8 minutes and three database nodes hit 100% CPU. What is the root cause and what is the standard solution?

Up Next · Lesson 27

Partitioning & Sharding

How NoSQL databases split data across nodes — and why the key you choose determines whether your cluster scales linearly or collapses under load.