SYSTEM DESIGN · CONCEPTS · DATABASES · EPISODE 19

Schema Design: Normalise or Denormalise

How to turn a product into tables: entities and relationships, primary and foreign keys, one-to-many and many-to-many with join tables, what normalisation protects you from, when to denormalise on purpose (read-heavy feeds, counters, caches), and the five-step way to design a schema in an interview.

  1. 1
  2. 2
  3. 3
  4. 4
  5. 5
  6. 6
  7. 7
  8. 8
  9. 9
  10. 10
  11. 11
  12. 12
  13. 13
  14. 14
  15. 15
  16. 16
  17. 17
  18. 18
  19. 19
  20. 20
  21. 21
  22. 22
  23. 23
  24. 24
  25. 25
  26. 26
  27. 27
  28. 28
  29. 29
  30. 30
  31. 31
  32. 32
  33. 33
  34. 34
  35. 35
  36. 36
  37. 37
  38. 38
  39. 39
  40. 40
  41. 41
1 / 17

Press play to hear the narration, or step through at your own pace. Every picture here is the same one used in the YouTube video.

Notes

Entities

Step one: find the entities. Read the product description and list the nouns. Users write posts, follow other users, and like posts. User, post, follow, like. Each noun becomes a table, and each table gets a primary key: a column that identifies one row, usually an id.

Here are users and posts. The important column is user_id in posts. It holds the id of the user who wrote the post. That's a foreign key: a column that points at a row in another table. One user, many posts. That's a one-to-many relationship, and it always lives as a foreign key on the many side.

Find the entities
  • read the product, list the nouns:
  • user, post, follow, like
  • each noun = one table
  • each table gets a primary key
users and posts
users
  id          bigint PK
  name        text
  email       text UNIQUE

posts
  id          bigint PK
  user_id     bigint FK → users.id
  body        text
  created_at  timestamp

Many-to-many

Following is different. A user follows many users, and is followed by many users. Many-to-many. You cannot store that as one column, because a column holds one value. So you add a third table, a join table, with one row per edge: this follower follows this followee. The primary key is the pair, so the same follow can't be stored twice. Likes are the same shape: user id, post id.

With those tables, the home feed is one query. Take the people I follow, join to their posts, sort by time, take fifty. The join is where relationships pay off: nothing is duplicated, so a post exists in exactly one place and a user's name exists in exactly one place.

follows
follows
  follower_id  bigint FK → users.id
  followee_id  bigint FK → users.id
  created_at   timestamp
  PRIMARY KEY (follower_id, followee_id)

one row per follow edge
The feed query
SELECT p.*
FROM posts p
JOIN follows f
  ON f.followee_id = p.user_id
WHERE f.follower_id = :me
ORDER BY p.created_at DESC
LIMIT 50;

Normalise

That property has a name: normalised. Every fact is stored once. If Priya changes her name, that's one update, and every post shows the new name because posts only store her id. You never end up with two copies that disagree. When people say third normal form, this is what they mean in practice: no repeated facts, and every column depends on the key.

Normalisation
  • every fact is stored once
  • change a name → one UPDATE
  • delete a user → nothing orphaned
  • no two copies that disagree
  • this is what “3rd normal form”
  • is protecting

The cost

The cost shows up at scale. Every feed request joins follows to posts to users, then sorts. With a thousand follows and millions of posts, that's real work, on every refresh, for every user. Reads are the common case here, by a hundred to one, and the normalised schema makes the common case the expensive one.

At scale
  • home feed =
  • follows (1,000 rows)
  • × posts (millions)
  • × users (names, avatars)
  • one request, three tables,
  • two joins, a sort

Denormalise

So you denormalise on purpose. When Priya posts, write one row into the feed of each of her followers: feed items, user id, post id, time. That's fan-out on write. Now reading my feed is a single range scan on my user id, no joins at all. You stored the answer instead of recomputing the question.

Nothing is free. One post is now N writes, and for a celebrity with ten million followers that's a problem you have to design around, usually by not fanning out for them. The data is copied, so deleting a post means deleting every copy. You've traded write cost and some consistency work for fast reads. That trade is the whole point, and you should say it out loud.

The same move at smaller scale: counters. Don't count likes with a query every time a post is shown; keep a like count column and increment it. Copy the author's name onto hot rows if the join hurts. The rule: denormalise what you read a thousand times more than you write, and keep the normalised source as the truth you can rebuild from.

feed_items
feed_items
  user_id     bigint   -- whose feed
  post_id     bigint
  created_at  timestamp
  PK (user_id, created_at, post_id)

read: WHERE user_id = :me
      ORDER BY created_at DESC LIMIT 50
no joins
The trade
  • writes: 1 post → N feed rows
  • (a celebrity: millions)
  • storage: the post id is copied
  • consistency: delete a post →
  • must delete N copies
  • reads: one table, no joins
Other places to denormalise
posts.like_count     int  -- counter
  not COUNT(*) FROM likes each time

posts.author_name    text -- copy
  avoid the users join on hot paths

users.follower_count int  -- counter

rule: denormalise what you read
      1000x more than you write

Details

A few details that signal experience. Primary keys: a bigint sequence, or a time-ordered UUID like version seven; random UUIDs scatter your index and hurt writes. Say what happens on delete: cascade, set null, or restrict. Timestamps in UTC with a timezone type. Money as integer minor units, never float. Status as an enum.

Keys and types
  • PK: bigint sequence, or UUID v7
  • (random UUID v4 fragments indexes)
  • FK with ON DELETE: decide it
  • CASCADE, SET NULL, or RESTRICT
  • timestamps: timestamptz, UTC
  • money: integer minor units
  • status: enum or small text

NoSQL

If the database is NoSQL, flip the order. You start from the queries, not the nouns. In a document database, embed what's read together, like a post with its author's name and its comments, and reference what isn't. In DynamoDB or Cassandra, you design one table per access pattern, keyed by exactly how you'll read it. Denormalised by default, because there are no joins to fall back on.

NoSQL: model the query
  • document DB: embed what is
  • read together, reference the rest
  • post { author:{id,name},
  • likes_count, comments:[…] }
  • DynamoDB / Cassandra: one table
  • per access pattern, keyed by
  • how you will read it

Method

Here's the method for the interview. One: nouns become tables, each with a primary key. Two: relationships become foreign keys; many-to-many gets a join table. Three: write down the three hottest queries. Four: index what they filter and sort on. Five: denormalise only the query that is still too slow, and name what it costs. In that order, every time.

The 5-step answer
  • 1. nouns → tables, each with a PK
  • 2. relationships → FKs,
  • many-to-many → join table
  • 3. write the 3 hottest queries
  • 4. index what they filter and sort on
  • 5. denormalise only the one
  • that is still too slow

In the interview

Say it like this. Users, posts, follows, likes; follows and likes are join tables. I'd keep it normalised first, so a name change is one update. The home feed is the hot read, so I'd precompute it into feed items on write, and skip the fan-out for accounts over a million followers. Four sentences, and you've shown the whole trade-off.

Say it like this
  • “Users, posts, follows, likes;
  • follows and likes are join tables.
  • Normalised first, so a name change
  • is one update. The home feed is the
  • hot read, so I’d precompute it into
  • feed_items on write, and skip the
  • fan-out for accounts over 1M
  • followers.”

Interview questions

How do you start designing a schema from a product description?

List the nouns (users, posts, follows, likes); each becomes a table with a primary key. Then list the relationships: one-to-many becomes a foreign key on the many side; many-to-many becomes a join table with a composite primary key. Then write the three hottest queries and index what they filter and sort on.

How do you model a many-to-many relationship?

With a join table that holds one row per pair: follows(follower_id, followee_id) with both columns as foreign keys to users and a composite primary key (follower_id, followee_id) so the same pair cannot be stored twice. Likes are the same shape: (user_id, post_id).

What does normalisation protect you from?

Storing the same fact in two places that can disagree. In a normalised schema a user’s name lives only in users, so renaming is one UPDATE and there is nothing to keep in sync. The cost is that reads that need several entities need JOINs.

When would you denormalise, and what does it cost?

When a read is far more frequent than the writes behind it (the home feed, like counts, follower counts) and the joins or aggregates are too slow at scale. Denormalising copies data or precomputes answers (feed_items on fan-out, a like_count column), which makes reads a single lookup but makes writes do more work and means updates and deletes must touch every copy.

What is fan-out on write?

When a user posts, insert one row into the precomputed feed of each follower, so reading a feed is a single range scan with no joins. For accounts with millions of followers, fan-out is skipped and their posts are merged in at read time instead.

Why not use COUNT(*) for like counts?

Counting rows scans the index for every display of the post. A like_count column on posts, incremented when a like is written, makes the count a single column read. The likes table stays the source of truth, so the counter can be rebuilt if it drifts.

How does schema design differ for NoSQL?

You design from the access patterns, not the entities. Document stores embed what is read together (a post with its author’s display name and recent comments) and reference the rest. Wide-column stores such as Cassandra and DynamoDB use one table per query pattern, keyed by exactly how the data will be read, because there are no joins.