Data modeling & storage engines
This is the lesson the rest of the course quietly leans on. Partitioning (07), replication (08), messaging (11) and transactions (12) all assume you know what a write-ahead log is, why a B-tree and an LSM-tree make opposite bets, and what an index physically costs. We build all of that here, from one idea: you model the queries, not the entities, and the engine underneath is just a machine for making your chosen queries cheap and the rest expensive.
A database is a trade between write cost, read cost, and space — you cannot minimise all three. Every decision in this lesson (normalise vs denormalise, SQL vs NoSQL, B-tree vs LSM, which indexes to build) is choosing which queries get to be cheap and paying for it somewhere else. Name the cost every time.
1. Model the queries, not just the entities
Newcomers start by drawing entities — User, Order, Product — and wiring up relationships. That is necessary but not sufficient. The schema that actually survives contact with production is shaped by the access patterns: what reads and writes happen, how often, and how big. Those numbers come straight out of the estimate step from lesson 01 — QPS, read:write ratio, payload sizes, growth.
So before any tables, write the query list. For a social feed it might be:
- "Get the 20 most recent posts by the people I follow" — extremely hot, must be fast.
- "Create a post" — moderate rate, can be a touch slower.
- "Count a user's followers" — read often, written rarely.
Those three sentences decide more about your design than the entity diagram does. The hot read ("recent posts by followees") tells you what to index, what to denormalise, and eventually what to shard on (07). The rule: list the top ~5 queries by frequency and latency-sensitivity first; the schema is whatever makes those cheap.
Entities, keys, relationships — the vocabulary
- Primary key: the unique identifier for a row (e.g.
user_id). It is also, in most engines, the key the data is physically sorted/located by — which is why choosing it well matters far beyond uniqueness. - Foreign key: a column that references another table's primary key (
order.user_id → user.id), encoding a relationship. - Cardinality: one-to-one, one-to-many (a user has many orders), many-to-many (students↔courses, via a join table). Many-to-many is the relationship that joins — and denormalisation — fight over.
2. Normalisation vs denormalisation — the core modelling trade
This is the first real fork, and it is the same write-vs-read trade as everything else, in miniature.
Normalise means: store each fact exactly once, in one place, and reference it by key. A user's display name lives only in the users table; an order row stores user_id, not the name. Benefits: one source of truth, so a name change is one write and nothing can drift. The cost: to show an order with the buyer's name, the read must join — go fetch the matching user row. Reads do more work.
Denormalise means: deliberately duplicate data so a single fetch answers the query. Store the buyer's name on the order row. Benefit: the read is one lookup, no join — fast. The cost: that name now lives in N places, so a change is N writes, and if any write is missed the copies drift (the dreaded update anomaly). You traded cheap writes and correctness-by-construction for cheap reads.
"Normalise until it hurts, denormalise until it works." Start normalised — it is the safe default, correct by construction. Denormalise only the specific hot read that the normalised join can't serve fast enough, and only once you can name what keeps the copies in sync. A cache (06) is itself a denormalised read copy — same trade, same staleness risk, just in a faster tier. The capstone (19) walks a timeline where a feed starts normalised and selectively denormalises the fan-out read.
| Approach | Read | Write | Buys | Cost |
|---|---|---|---|---|
| Normalised | Join (slower) | One write, one place | One source of truth; no update anomalies; flexible ad-hoc queries | Reads pay join cost; can't always serve the hot read fast enough |
| Denormalised | Single fetch (fast) | Update every copy | Fast single-fetch reads; no joins; shard-friendly | Duplicate data drifts (update anomaly); writes fan out; more space |
3. SQL vs NoSQL — a decision, not a religion
The framing that loses interviews is "NoSQL is web-scale, SQL is old." The framing that wins: relational and non-relational engines make different default trades, and you pick by your access patterns and consistency needs.
Relational (SQL) — Postgres, MySQL. Data in tables with a fixed schema; the engine does joins for you, supports ACID transactions (Atomic, Consistent, Isolated, Durable — see 12), and answers ad-hoc queries you didn't plan for (a query planner figures out how). You pay with a rigid schema and harder horizontal scaling (joins across machines are expensive — the reason sharding in 07 is painful).
Non-relational (NoSQL) is a family, not one thing:
- Key–value (Redis, DynamoDB): a giant hash map,
key → blob. Blazing point lookups, trivial to shard by key. No queries other than "get by key." - Document (MongoDB): JSON-ish documents, flexible schema, query within a document. Great when data is naturally a nested aggregate fetched together.
- Wide-column (Cassandra, Bigtable): rows keyed by a partition key, each holding many columns; the table is literally shaped around one access pattern. Enormous write throughput, linear horizontal scaling.
The shared theme of NoSQL: flexible or access-pattern-shaped schema, horizontal scale, and no joins. No joins is the load-bearing constraint — it forces you to denormalise and model one table per query. You give up ad-hoc flexibility and (often) multi-key transactions to get scale and predictable single-key latency.
| Dimension | Relational (SQL) | NoSQL (KV / doc / wide-col) |
|---|---|---|
| Schema | Rigid, enforced up front | Flexible / per-access-pattern |
| Joins | Yes (engine does them) | No — you denormalise |
| Ad-hoc queries | Strong (query planner) | Weak — only planned access paths |
| Transactions | Full ACID, multi-row | Often single-key only |
| Horizontal scale | Harder (cross-shard joins) | Native, linear |
| Cost you pay | Scaling pain past one box | Modelling rigidity + duplication |
| Reach for it when… | Relationships, ad-hoc reporting, strong invariants | Known hot access pattern, huge write volume, scale-out |
Mature systems use several stores, each for what it's best at — Postgres for the transactional core, Redis for the hot cache, Cassandra for the high-volume event log, Elasticsearch for full-text search. This is polyglot persistence. The interview-grade instinct: don't pick one database for the whole system; pick the right store per access pattern, and accept the cost of keeping them in sync (often via the change log we meet next, see 11).
4. Storage-engine internals — the heart of the lesson
Now the part later lessons assume. SQL-vs-NoSQL is a packaging question; underneath, a database is a storage engine — code that turns "set key K to value V" and "get key K" into disk I/O. Two facts about disks drive everything:
- Sequential writes are ~100× cheaper than random writes (append to the end of a file vs seek to a random spot and overwrite). This gap was enormous on spinning disks (a seek costs milliseconds); it shrinks on SSDs but stays real, because random writes still trigger erase-block churn and write amplification. Re-use the latency ladder from 01: a disk seek dwarfs a sequential append.
- A crash can happen mid-write. Durability means a committed write survives a power loss — even if the box dies one instruction later.
The write-ahead log (WAL) — durability, cheaply
Here is the trick that makes durable databases fast. Before touching the actual data structure (which lives in random places on disk), the engine first appends the change to a sequential log — the write-ahead log — and flushes that. Only then does it update the data in place (or it does so lazily, in the background). The rule is in the name: write to the log ahead of writing the data.
Why this is brilliant: the append is sequential (cheap), so the latency the user waits for is one fast sequential write, not a slow random one. Durability is satisfied the instant the log entry is flushed. Think of a WAL like a kitchen's order-ticket rail: the server spikes the written ticket on the rail before anyone starts cooking, so if the kitchen is overwhelmed or "crashes" mid-service, the tickets are the durable record of what was promised, and you replay them to rebuild every in-flight order. And on crash recovery, the engine simply replays the log from the last checkpoint — every committed change is there, in order — to rebuild the correct on-disk state. You get durability at the price of a sequential append instead of a synchronous random write.
The same ordered log of changes is what these later lessons are built on:
- Replication (08) ships the WAL to follower replicas, which replay it to stay in sync. "Streaming replication" is shipping the log.
- Change-data-capture / outbox (12) tails the same log to emit a stream of every change to other systems — keeping that polyglot zoo consistent without dual-writes.
- Messaging (11): a durable log is the data structure, as we'll see with Kafka below.
One idea — an ordered, append-only log of changes — underlies durability, replication, CDC, and event streaming. Recognising it as the same thing is a senior signal.
B-tree — read-optimised, update-in-place
The default engine for relational databases (Postgres, MySQL/InnoDB). A B-tree keeps keys sorted on disk in a balanced tree of fixed-size pages (typically 4–16 KB). To find a key you walk from the root down: each node read narrows the search, so a point lookup is O(log n) — a handful of page reads even for billions of rows. Because keys are sorted and adjacent, range queries ("all orders between March and April") are also cheap: find the start, then scan sequentially.
Writes update in place: find the right page, modify it, write it back. The cost lives here:
- Write amplification: changing one row rewrites a whole page; if the page is full, it splits into two and parent pointers update — one logical write becomes several physical ones.
- In-place overwrite is unsafe across a crash (a half-written page corrupts the tree), which is exactly why a B-tree engine needs the WAL — log first, then overwrite.
Net: a B-tree makes reads (point and range) cheap and pays on writes. It is the right default for read-heavy and mixed workloads.
LSM-tree — write-optimised, append + compact
The log-structured merge-tree (Cassandra, RocksDB/LevelDB, ScyllaDB) makes the opposite bet: never update in place, only append. The flow:
Walk it: a write lands in an in-memory sorted memtable (and is appended to a WAL for durability). When the memtable fills, it is flushed as an immutable sorted file — an SSTable (Sorted String Table). Writes are thus pure sequential appends — fast, the whole point. Over time SSTables pile up, so a background process called compaction merges them: combining sorted files, keeping only the newest value for each key and physically dropping deleted/overwritten ones.
SET k=1→ appended to the WAL and written to the in-memory memtable (fast, sequential).- The memtable fills → it is flushed as an immutable sorted file (an SSTable) at level 0.
- Later
SET k=2→ a new append; the oldk=1is still sitting on disk in the L0 SSTable, now stale. - Read
k→ check the memtable first, then the SSTables newest→oldest (Bloom filters skip files that can't contain it) → the newest value,2, wins. - Background compaction merges the overlapping SSTables, keeps only the newest
k=2, and physically drops the stalek=1— reclaiming the space and shrinking future reads.
This is why writes are cheap (always an append) but reads and space pay for it (multiple files plus compaction).
The costs, named:
- Read amplification: a key could be in the memtable or any SSTable level, so a read may check several places. Mitigated by a Bloom filter — a tiny probabilistic bitset per SSTable that answers "is key K definitely not here?" so reads skip files that can't contain the key (it never gives a false negative).
- Compaction overhead: merging rewrites data repeatedly (CPU + extra write I/O = its own write amplification over the file's life) and transiently doubles space (old + new files coexist) — space amplification.
A message log like Kafka (11) is essentially the append half of an LSM-tree: producers append records to the end of a partition's segment files; consumers read sequentially by offset. There is no in-place update, no random I/O — just sequential append and sequential read, which is precisely the cheap disk pattern from principle (1). That is the mechanical reason Kafka sustains millions of messages/sec on ordinary disks: it does only the operation disks are good at.
B-tree vs LSM-tree — the trade table
| Property | B-tree (Postgres, InnoDB) | LSM-tree (Cassandra, RocksDB) |
|---|---|---|
| Write path | In-place update + page split | Append to memtable, flush, compact |
| Write amplification | Higher (page rewrites/splits per write) | Lower per write, but compaction adds its own over time |
| Read amplification | ~1 (one tree walk) | Higher (multiple levels; Bloom filters help) |
| Space amplification | Low–moderate (some page fragmentation) | Higher transiently (old + new SSTables during compaction) |
| Range scans | Excellent (sorted, adjacent on disk) | Good but merges across levels |
| Best workload | Read-heavy / mixed, range queries, ad-hoc | Write-heavy ingest, time-series, high throughput |
One sentence to carry: read-heavy with range scans → B-tree; write-heavy ingest → LSM. The widget below makes that quantitative.
5. Indexes — turning O(n) into O(log n)
A query like WHERE email = 'x@y.com' on an unindexed table must read every row to find matches: a full table scan, O(n). On 100M rows that's seconds. An index fixes it.
Physically, an index is a second sorted data structure (usually its own B-tree, or built into the LSM) that maps a column value → the locations of rows with that value. Because it's sorted, a lookup is O(log n) — a few page reads. That's how a 1-second scan becomes a 1-millisecond lookup. The gain is biggest when the column is selective (few rows per value — like email); indexing a low-selectivity column (e.g. a boolean) barely helps, because you still read a huge fraction of rows.
The cost — and there is always a cost:
- Every write must update every index. Five indexes means an insert does six writes (table + 5 indexes). Indexes trade write throughput and space for read speed — the same trade as the whole lesson.
- They consume disk and memory (the index must be kept hot to be fast).
So: index the columns your hot reads filter/sort on, and don't index columns nothing queries — each one is a tax on every write.
Composite, covering, and the clustering key
- Composite index on
(a, b): sorted byathenb. Serves filters ona, and ona AND b— but notbalone (the leftmost-prefix rule). Order the columns to match your query. - Covering index: includes every column the query needs, so the engine answers from the index alone and never touches the table (an "index-only scan"). Buys a skipped lookup; costs a wider index.
- Primary / clustering key: the key the table's rows are physically ordered by on disk (an InnoDB table is its primary-key B-tree). Rows with adjacent keys sit together, so range scans on that key are nearly free — which is exactly why the shard-key choice in 07 matters: it is the clustering key at cluster scale, and a bad one creates hotspots. That lesson also covers local vs global secondary indexes — whether each shard indexes only its own data (cheap writes, scatter-gather reads) or there's one cluster-wide index (cheap reads, expensive writes) — the same read/write trade, distributed.
6. Schema evolution — changing the model without downtime
Schemas change while the system is live and old code is still running. The discipline is the same as API versioning (03): make changes backward-compatible so old and new can coexist.
- Additive changes are safe: adding a nullable column or a new table breaks nothing — old code ignores it, new code uses it.
- Destructive changes are not: renaming or dropping a column that running code reads will crash it.
- Online migrations: on a big table, an
ALTERthat rewrites or locks every row can stall writes for minutes. Use tools / techniques that build the change in the background and swap atomically.
To rename name → full_name with zero downtime, never do it in one step:
- Expand: add
full_name(additive, safe). Deploy code that writes both columns and readsfull_namewith fallback toname. - Migrate: backfill
full_namefromnamefor existing rows, in batches. - Contract: once all code reads
full_nameand the backfill is done, stop writingnameand drop it.
At every step old and new code work against the same database. This is the data-layer twin of additive, versioned API changes — the cost is temporary duplication and a multi-deploy dance, bought in exchange for never taking the system down.
Try it: a B-tree vs LSM workload advisor
Pick a read:write mix, say how much range scans matter, and set write volume. The widget estimates a rough write-amplification and read-amplification for each engine and recommends one. The model is deliberately simple but directionally honest — see the hint for the formula.
Interview prompts you should be ready for
- "What is a write-ahead log and why does it exist?" (senior answer: durability cheaply. The engine appends the change to a sequential log and flushes that before updating data in place; the append is sequential so it's ~100× cheaper than a random write, and on crash you replay the log to recover committed state. Bonus: that same log is what replication ships and what CDC tails — one structure, many uses.)
- "B-tree vs LSM-tree — when each?" (senior answer: B-tree updates in place, sorted on disk → read-amp ~1 and excellent range scans, at the cost of write amplification from page splits; pick it for read-heavy/mixed and ad-hoc queries — Postgres-style. LSM only appends (memtable → SSTable → compaction) → low per-write cost and huge ingest throughput, at the cost of read amplification (mitigated by Bloom filters) and compaction CPU/space; pick it for write-heavy/time-series — Cassandra/RocksDB.)
- "SQL or NoSQL for a product catalogue with ad-hoc filtering and reporting?" (senior answer: SQL. The decisive signals are relationships, ad-hoc queries you can't pre-plan, and strong invariants — exactly what a relational engine's joins, planner and ACID give you. Reach for NoSQL when the access pattern is known and fixed and write volume/scale-out dominates; even then, polyglot — keep the transactional core relational.)
- "What does adding an index cost?" (senior answer: it speeds the matching read from O(n) full scan to O(log n) lookup, biggest on selective columns — but it's a second sorted structure that every write must also update, so N indexes turn one insert into N+1 writes, plus disk/memory. Index the columns hot reads filter or sort on; don't index what nothing queries.)
- "This page joins users to orders on every request and it's slow — normalise or denormalise?" (senior answer: measure first, then denormalise the specific hot read — copy the few user fields the page shows onto the order — but only after deciding how the copies stay in sync (write-time fan-out, or a cache/CDC-fed read model). Name the cost: writes now update every copy and copies can drift. Keep the rest normalised; 'normalise until it hurts, denormalise until it works.')
- "Why are sequential writes so much cheaper than random ones, and how do databases exploit it?" (senior answer: a random write means a seek/erase-block cycle; an append doesn't — it's ~100× cheaper, per the latency ladder. WALs exploit it for durability (append the log, defer the in-place update), and LSM-trees/Kafka exploit it for throughput by making all writes appends — that's literally why Kafka is fast.)
- "How would you rename a column on a 500M-row live table with no downtime?" (senior answer: expand–contract. Add the new column (additive, safe), deploy code that writes both and reads new-with-fallback, backfill in batches, then once all reads use the new column stop writing and drop the old one. Old and new code coexist at every step — same discipline as backward-compatible API versioning.)
- "What is an index physically, and why doesn't a missing-key lookup hit disk in an LSM?" (senior answer: an index is a secondary sorted structure mapping column value → row locations, so lookups are O(log n). In an LSM, a per-SSTable Bloom filter — a probabilistic bitset with no false negatives — answers 'definitely not here,' letting a read skip files that can't contain the key, which is what keeps read amplification in check.)
A database is a machine for trading write cost, read cost, and space — and you decide which queries are cheap by modelling the access patterns first. Normalise for one source of truth, denormalise the hot read and pay to keep copies in sync. SQL gives joins/ACID/ad-hoc at the price of scale-out pain; NoSQL gives scale-out at the price of no-joins and per-query modelling. Underneath, the WAL buys durability with a cheap sequential append (and is what replication ships and CDC tails); a B-tree optimises reads/ranges and pays write amplification, while an LSM-tree optimises writes (append + compact, Bloom-filtered reads) and is why Kafka is fast. Indexes turn O(n) scans into O(log n) lookups but tax every write. With the data layer settled, the next question is how to put more boxes behind it — 05 · Horizontal scaling & load balancing.