Week 4: Choosing a Data Model: SQL vs. NoSQL

Week 3 established that a system's load balancer and app tier can scale out largely by removing state from the app servers — but that state has to live somewhere, and this week is about the layer it lands on: the database. Before anything can be replicated or sharded (Week 5) or sit behind a cache (Weeks 6–7), you have to pick a data model that actually fits how the system reads and writes, choose indexes deliberately rather than by default, and decide how aggressively to normalize. Get this choice wrong and every later scaling technique is fighting the model instead of working with it; the strong vs. eventual consistency tradeoffs different data stores make here are also the direct setup for the CAP theorem in Week 9.

Module 4 of 24 Week 4 of 28 ~3–4 Hours Hands-on Exercise Included

By the end of this week, you'll be able to

  • Match a workload's access patterns to relational, document, key-value or wide-column data models
  • Explain how a B-tree index speeds up reads and why every index has a write-side cost
  • Decide when denormalizing data is a deliberate scaling win rather than sloppy design

1. Relational vs. Document/Key-Value/Wide-Column

A relational (SQL) database organizes data into tables with a fixed schema, relates rows across tables with foreign keys, and lets you query across those relationships with joins. Its defining strength is ACID transactions (Atomicity, Consistency, Isolation, Durability) — a multi-step write either fully happens or fully doesn't, and concurrent transactions don't corrupt each other's view of the data. That makes relational databases the default choice whenever correctness across related records matters more than raw write throughput: financial transactions, inventory counts, anything where "half-applied" is unacceptable.

NoSQL is really an umbrella over several different models, each trading away some of the relational database's generality for a specific access pattern's sake:

the three common NoSQL shapes
Key-value (e.g. Redis, DynamoDB):
  get(key) -> value, set(key, value) -- no query language, no
  joins, but extremely fast and trivially shardable by key.
  Fits session storage, caching, feature flags.

Document (e.g. MongoDB):
  Each record is a self-contained JSON-like document, fields
  can vary between documents, nested structures allowed.
  Fits data that's naturally hierarchical and usually read/written
  as one whole unit -- a user profile, a product catalog entry.

Wide-column (e.g. Cassandra, HBase):
  Rows identified by a key, but columns can vary per row and are
  grouped into column families, optimized for very high write
  throughput distributed across many nodes.
  Fits time-series data, event logging, IoT sensor data.

The interview-relevant point is not "NoSQL scales better than SQL" — that's a common oversimplification. Both models can scale horizontally with the right architecture (Week 5 covers sharding, which applies to relational databases too). The real question is which model's native strengths match your access pattern: if the system needs multi-row transactions and rich ad hoc queries across related entities, relational is usually the better starting point; if the system needs simple lookups by a single key at very high throughput, or naturally schema-flexible nested documents, a NoSQL model removes friction the relational model would otherwise impose.

Justify by access pattern, not by reputation

"I'd use NoSQL because it's more scalable" is a weak answer that many candidates default to. A stronger answer names the actual access pattern: "orders need multi-table transactions and reporting joins, so relational; the session store needs single-key lookups at very high QPS with no relationships, so a key-value store." Two data stores in the same system is completely normal and often the right call.

2. Indexing Strategies

Without an index, finding a row that matches a condition means scanning every row in the table — a full table scan, O(n) in the number of rows. An index is a separate, sorted data structure that lets the database jump directly to matching rows instead. The default structure most relational databases use is a B-tree: a balanced, sorted tree where each lookup, insert or delete takes O(log n) rather than O(n), and — critically — the sort order also makes range queries (WHERE created_at > ?) fast, not just exact-match lookups.

scan vs. index — 10 million row users table
SELECT * FROM users WHERE email = 'ana@example.com';

Without an index on email:
  full table scan -- database checks up to 10,000,000 rows

With a B-tree index on email:
  O(log n) traversal -- roughly log2(10,000,000) ~ 24 comparisons
  to locate the matching row(s)

Same idea applies to range queries:
  WHERE created_at BETWEEN '2026-01-01' AND '2026-01-31'
  A B-tree index on created_at lets the database jump to the
  start of the range and scan forward only through matching rows,
  instead of checking every row in the table.

A composite index spans multiple columns, and column order in the definition matters — a composite index on (last_name, first_name) can efficiently serve a query filtering on last_name alone, or on both columns together, but cannot efficiently serve a query filtering on first_name alone. This is the leftmost prefix rule: a composite index is usable only for query conditions that match a prefix of its column order, left to right.

Every index has a real cost, though: it must be updated on every INSERT, UPDATE or DELETE that touches an indexed column, so more indexes mean slower writes, and each index also consumes its own storage. Indexing is therefore a genuine tradeoff, not a free performance upgrade — the right move is indexing the columns your actual queries filter or sort on, not every column that might someday be useful.

Read-heavy vs. write-heavy changes the answer

A read-heavy table (like a URL shortener's lookup table from Week 1) can usually afford several well-chosen indexes since the read speedup vastly outweighs the write cost. A write-heavy table (like an event log ingesting thousands of writes per second) should carry the fewest indexes it can get away with, because every index tax gets paid on every single write. State which side of that ratio your table is on before deciding how aggressively to index it.

3. Normalization vs. Denormalization

Normalization organizes data to minimize redundancy: each fact is stored in exactly one place, and related data is split across tables connected by foreign keys. Its payoff is data integrity — updating a user's name means updating one row, not hunting down every copy of it — but reconstructing a full picture (a user with their orders and each order's line items) requires joining across tables, which gets more expensive as tables grow and, in a sharded or distributed database, can become outright impractical if the related rows don't live on the same node.

Denormalization deliberately duplicates data to avoid those joins — storing a copy of frequently-needed fields directly on the record that needs them, so a single read satisfies the query without touching other tables. The cost moves to writes: every copy of a duplicated fact has to be kept in sync, and a bug or a missed update path can leave copies inconsistent with each other.

normalized vs. denormalized — an order record
Normalized (3 tables, joins required to render an order):
  orders(id, user_id, created_at)
  order_items(id, order_id, product_id, quantity)
  products(id, name, price)
  -- Rendering one order's receipt needs a join across all three

Denormalized (one document, no joins needed):
  {
    "order_id": 501,
    "user_name": "Ana Ruiz",          // duplicated from users
    "items": [
      { "product_name": "Mug", "price": 12.00, "quantity": 2 }
      // product_name and price duplicated from products
    ]
  }
  -- One read returns everything; but if a product's price
     changes, every past order's denormalized copy is unaffected
     by design (a receipt should show the price paid, not today's
     price) -- here duplication is actually correct, not a bug

That last detail matters: denormalization isn't always "stale data you have to fix" — sometimes the duplicated copy is supposed to be a frozen snapshot (an order receipt should show the price paid at purchase time, not today's price), and in that case denormalization is the more correct model, not merely a faster one. The general principle for a system design interview: normalize by default for correctness-critical, relationally complex data, and denormalize deliberately, table by table, wherever a specific read pattern is both frequent and expensive under the normalized form — exactly the same "the numbers justified this" discipline Week 1 introduced for adding a cache.

Denormalization is a scaling tool, not a mistake

Large-scale systems denormalize constantly and on purpose — a news feed service (Week 14) commonly denormalizes a post's author name and avatar directly onto each feed entry, accepting eventual staleness if the author later changes their name, because re-joining against a users table on every feed render at that scale would be far more expensive than the rare inconsistency is costly. Naming that tradeoff explicitly is stronger than treating denormalization as something to avoid.

4. Hands-on Exercise

Hands-on

Model the data layer for a comment system

Design the data model for comments on a blog platform: users can post comments on articles, reply to other comments (nested), and each article displays a live comment count.

Requirements:

  1. Choose a data model (relational, document, key-value or wide-column) for storing comments themselves, and justify it against at least one alternative based on this system's access pattern (nested replies, read by article).
  2. List the queries this system needs to serve well (e.g. "fetch all top-level comments for an article, newest first") and propose one index — noting whether it's a single-column or composite index — for each.
  3. Decide whether the per-article comment count should be computed live with a query (e.g. COUNT(*)) or stored as a denormalized counter field, and justify your choice against the read/write pattern of a popular article.
  4. If you chose a denormalized counter, describe in 2–3 sentences one specific way it could drift out of sync with the real count, and one way to guard against that.
  5. Write one sentence on the write-side cost of the indexes you proposed in question 2, and whether that cost is acceptable given comments are written far less often than they're read.
Hint

For question 3, think about a viral article with 50,000 comments getting read thousands of times a minute — recomputing COUNT(*) over 50,000 rows on every single page view is a very different cost than incrementing a counter once per new comment. That asymmetry is exactly the kind of read:write ratio reasoning Section 3 asks you to apply.

5. Knowledge Check

Four quick questions. Expand each to check your answer.

Q1

Why is "NoSQL is more scalable than SQL" considered an oversimplification in a system design interview?

Relational databases can also scale horizontally through sharding and replication (Week 5) — scalability is a property of the overall architecture, not something only NoSQL data stores possess. The real distinguishing factor between the models is which access pattern they natively fit: multi-row transactions and relational queries favor SQL, while simple high-throughput key lookups or flexible nested documents favor a NoSQL model — the choice should follow the access pattern, not a general scalability reputation.

Q2

A table has a composite index on (country, city). Will a query filtering only on city use this index efficiently?

No — under the leftmost prefix rule, a composite index is only usable for query conditions that match a prefix of its column order starting from the left. A query filtering on city alone skips the leftmost column (country) entirely, so the database cannot use this index efficiently and would fall back to a full scan or a separate index on city specifically.

Q3

A write-heavy event-logging table ingests 50,000 writes per second and is rarely queried. How should that shape its indexing strategy?

Every index on this table is updated on every one of those 50,000 writes per second, so the table should carry the fewest indexes it can get away with — ideally only what's strictly necessary for the rare queries that do run against it. This is the opposite strategy from a read-heavy table, where the read speedup from several well-chosen indexes usually outweighs the write cost by a wide margin.

Q4

An order receipt stores the product's price at the time of purchase, duplicated from the products table. Is this a normalization bug that should be fixed, or is it correct design?

It's correct design, not a bug: a receipt is meant to be a frozen snapshot of what was actually paid, so the duplicated price should stay fixed even if the product's current price later changes — "staying in sync" with the live products table would actually be the wrong behavior here. This is the general point about denormalization: whether duplicated data should track the source of truth or intentionally diverge from it depends on what the field is supposed to represent.