Skip to the chapter
←System DesignGGroundworkSDSystem Design
System Design/Beginner/B4
4 / 36 written chapters
/

The cover
  1. B1The mental model
  2. B2Client-server basics
  3. B3Vertical vs horizontal
  4. B4Databases in design
  5. B5Caching fundamentals
  6. B6APIs & communication
  7. B7Walkthrough: URL shortener
  1. I1Load balancing in depth
  2. I2CDNs
  3. I3Message queues & async
  4. I4Consistency models
  5. I5Replication
  6. I6Rate limiting & throttling
  7. I7Designing for availability
  8. I8Storage systems
  9. I9Search systems
  10. I10Microservices
  11. I11Security basics
  12. I12Walkthrough: feed/chat
  13. I13Walkthrough: notifications
  1. A1CAP theorem in depth
  2. A2Transactions and isolation
  3. A3Storage engines
  4. A4Sharding at scale
  5. A5Time, ordering and IDs
  6. A6Distributed consensus
  7. A7Batch & stream processing
  8. A8Fault tolerance
  9. A9Observability at scale
  10. A10Release safety
  11. A11Capacity estimation
  12. A12Case studies
  13. A13Walkthrough: key-value store
  14. A14Walkthrough: web crawler
  15. A15Walkthrough: payments
  16. A16Tradeoff thinking

SDB4 · Part 1 · Beginner

Databases in system design

The choice is decided by access pattern and consistency need — "SQL doesn't scale" is a claim that will cost you the interview.

19 min read2 diagrams9 sections0 exercises
4 of 36

In this chapter

  • The axis is access pattern, not scale
  • The families, and what each is genuinely good at
  • Transactions: what "ACID" hides
  • The same data, modelled two ways
  • Indexes: what they buy and what they cost

and 4 more

about 19 min
09On this page
  1. The axis is access pattern, not scale
  2. The families, and what each is genuinely good at
  3. Transactions: what "ACID" hides
  4. The same data, modelled two ways
  5. Indexes: what they buy and what they cost
  6. Read-heavy and write-heavy are different problems
  7. Connection pooling, the bottleneck nobody draws
  8. Where "just use Postgres" is the right senior answer
  9. Recognizing it in an unseen problem

The axis is access pattern, not scale

Candidates are taught a false dichotomy: relational databases are for small consistent things, NoSQL is for big fast things, so pick NoSQL when the prompt says "millions of users." Interviewers at senior level are specifically listening for this and will push back, because it is wrong on the facts. Relational systems run at enormous scale (a large fraction of the world's payment and social infrastructure is sharded MySQL or Postgres), and plenty of NoSQL deployments are small.

The honest framing is that these systems made different bargains. Relational databases keep joins, ad-hoc queries, and multi-row transactions — features that are hard to distribute — so distributing them is work you do yourself. Early NoSQL systems dropped those features up front, which is exactly what lets them partition automatically. Many have since added limited versions: MongoDB supports multi-document ACID transactions (replica sets since 4.0, sharded clusters since 4.2) and DynamoDB offers all-or-nothing TransactWriteItems across up to 100 items within one region. Both document a performance cost, and both still assume a data model built around single-key access. So the question is never "how much data?" It is: do I know my access patterns in advance, and what do I need to be true across rows at once?

Question to askPushes you relationalPushes you non-relational
Do I know every query up front?No — the product will invent new ones monthlyYes — two or three fixed lookups, forever
Do I need to combine entities at read time?Yes — joins across users, orders, itemsNo — one key returns everything needed
Do I need multi-row atomicity?Yes — transfers, inventory, bookingsSingle-key atomicity is enough
What is the write rate to one logical key?Anything a single primary can absorbBeyond one node's write capacity, and the keyspace splits cleanly
Does the shape of the data vary per record?No — a stable schema is an assetYes — heterogeneous, sparse, evolving documents
How bad is stale or lost data?Unacceptable — money, medical, legalTolerable — likes, views, telemetry, feeds
Say it like this → "I'd start relational. The access patterns here involve joining users to orders to line items, we need a transaction across two of those tables, and the product is going to keep inventing queries. If the event-log table becomes the write bottleneck I'd move that one table to a wide-column store, rather than moving the whole system."

Dry run: the six questions applied to a chat app's message history

QuestionApplied to "last 50 messages in a conversation"Conclusion
Do I know every query up front?Yes — essentially one: fetch the most recent messages for one conversation id, always in the same orderPushes non-relational
Do I need to combine entities at read time?No — a message row is self-contained; the read never joins across conversationsPushes non-relational
Do I need multi-row atomicity?No — each message insert is independent; nothing needs two messages written togetherPushes non-relational
What is the write rate to one logical key?Any one conversation writes at a modest rate, even a busy group chat; the aggregate across all conversations is huge, and the keyspace splits cleanly by conversation idPushes non-relational (mildly): no hot key, easy partitioning
Does the shape of the data vary per record?No — sender, text, timestamp is a stable shapeNeutral — doesn't argue against either
How bad is stale or lost data?A few seconds of replication lag is invisible; losing an acknowledged message is not, so writes still need a durable per-key acknowledgementRules out pure best-effort, but not non-relational — just requires a real write concern

Four of six questions point the same way (one is neutral, one only constrains the write path), which is the signal: a wide-column store keyed by (conversation_id, bucket), clustered by message id descending, matches every access pattern here without paying for joins or multi-row transactions the workload never uses. A relational store sharded by the same key would also work; the argument is about what you no longer have to operate and pay for, not about what is impossible.

The families, and what each is genuinely good at

FamilyData modelGenuine strengthReach for this when…
Relational (Postgres, MySQL) Tables, rows, foreign keys, a declarative query planner Ad-hoc queries, joins, ACID transactions, constraints that make invalid states unrepresentable The default. Anything with entities and relationships, and any time you can't yet enumerate the queries
Key-value (Redis, Memcached, DynamoDB in its simplest use) Opaque value behind a single key Sub-millisecond point lookups, trivially partitionable, very high throughput Caching, sessions, rate limit counters, leaderboards, feature flags — anything you always fetch by exactly one key
Document (MongoDB, DynamoDB, Couchbase) Nested JSON-ish documents, keyed, secondary indexes available Fetching one self-contained aggregate in a single read; per-record schema flexibility The read unit and the write unit are the same object — a product listing, a user profile, a CMS page
Wide-column (Cassandra, ScyllaDB, HBase, Bigtable) Partition key plus sorted clustering keys; rows can be sparse and wide Enormous write throughput on an LSM engine, linear scale-out, efficient range scans within a partition, multi-datacenter replication Time series, event logs, messages/feeds per user — huge writes, queries always scoped to one partition key
Graph (Neo4j, and graph layers on top of relational) Nodes and edges as first-class, traversal-oriented query language Variable-depth traversal — "friends of friends who like X", shortest path, fraud rings The relationships are the query and depth is unbounded; a 2-hop join in SQL is fine, a 6-hop one is not
Search (Elasticsearch, OpenSearch) Inverted index over analysed text plus filters and facets Relevance-ranked full-text search, faceting, fuzzy matching Users type free text into a box; treat it as a derived index fed from your source of truth, never as the source of truth
Columnar / OLAP (ClickHouse, BigQuery, Snowflake, Redshift) Column-oriented storage, heavy compression, vectorised scans Aggregating billions of rows over a few columns in seconds Analytics and reporting. Also the correct answer to "how do we stop the analytics team from taking production down"
Object storage (S3 and equivalents) Immutable blobs behind a key, HTTP access Effectively unlimited capacity at very low cost per GB, extreme durability Images, video, backups, data lake files. Store the bytes here and the metadata in your database — never the bytes in the database

Product facts move, so check currency before you name a vendor. Redis changed to source-available licences in March 2024, the Linux Foundation forked it into a community project, and Redis 8 added AGPLv3 as an option in May 2025. CockroachDB retired its free self-hosted Core edition in late 2024 in favour of one enterprise licence (free for small companies). In an interview the distinction that matters is the data model and consistency behaviour; the licence is a procurement detail you can mention in one clause.

Transactions: what "ACID" hides

"ACID" is a bundle, and the letter that surprises people is the I. Isolation is a dial, not a switch. PostgreSQL defaults to Read Committed, where two statements in one transaction can see different committed data. Repeatable Read is snapshot isolation: a stable view, but two transactions can each read a shared fact and write different rows based on it (write skew), for example two on-call doctors each going off call after checking that the other is still on. Serializable (implemented in PostgreSQL as serializable snapshot isolation) rules that out, at the price that the database may abort a transaction with a serialization failure, which your code must retry. Say which isolation level a money-moving path needs, and what retries it. The transaction chapters go further.

⚠ Naming a database is not a design decision "I'll use Cassandra" earns nothing. "I'll use a wide-column store keyed by (user_id, bucket) with messages clustered by timestamp descending, because every read is the last fifty messages for one user and the write rate is 100k/sec" earns the whole section. The store follows from the key design; present them together or the interviewer cannot tell whether you understand the choice or memorised it.

The same data, modelled two ways

normalized: one fact, one place users (id, name, avatar) posts (id, author_id, body) comments (id, post_id, ...) denormalized: one document post: id, body, created_at author: name, avatar (copied) comments: [ text, author_name, ... 50 more embedded ] read = 2 joins rename author = 1 row updated read = 1 lookup, no joins rename author = N documents rewritten
Denormalization does not remove work, it moves it from read time to write time — and buys that with the risk that two copies of a fact disagree.

Normalize by default: one fact in one place means an update is one write and contradictions are impossible. Denormalize deliberately, when a measured read path is too expensive and the data is read far more often than it changes. The three costs you must name when you propose it are write amplification (one logical change touches many records), update anomalies (copies drift, and you now need a repair job), and growth (an embedded array of comments is unbounded, and most document stores have a hard per-document size limit).

NormalizedDenormalized
Read costJoins at query time, planner-dependentOne lookup by key
Write costOne rowFan-out to every copy, often asynchronously
ConsistencyEnforced by the databaseYour responsibility, and eventual at best
Schema changeOne migrationBackfill every copy
Reach for this when…Default — until profiling says otherwiseRead/write ratio is extreme, the shape is stable, and staleness between copies is acceptable

Notice the symmetry with feed design: a normalized model is fan-out-on-read, a denormalized one is fan-out-on-write. It is the same tradeoff at a different altitude, which is worth saying out loud — interviewers reward candidates who recognise a pattern they have already discussed.

Indexes: what they buy and what they cost

An index is a second data structure — usually a B-tree — that maps column values to row locations in sorted order, turning a full table scan into a logarithmic descent. That is the benefit, and it is enormous: on a ten-million-row table it is the difference between seconds and microseconds. The cost is paid on every write, and candidates almost never mention it unprompted.

INSERT one row table heap page write-ahead log (fsync) index on (user_id) index on (created_at) one logical write = 4+ physical writes Each extra index is a permanent tax on every insert, update and delete of that table.
Indexes are not free storage tricks; they are a read/write tradeoff you are making on behalf of every future write. (Strictly, heap and index pages are changed in memory and flushed later, and the log write is sequential and shared by many commits. The point stands: every index is extra work on every write.)
  • Write amplification. Each secondary index adds a structural update per write, often at a random location in the tree. On a write-heavy table the cost grows with every index you add (benchmark your own table rather than trusting a rule of thumb), and index maintenance also inflates the WAL.
  • Selectivity decides everything. An index only helps when it eliminates most rows. An index on a boolean column with a 50/50 split is worse than useless — the planner will correctly ignore it, because random-access lookups for half the table cost more than a sequential scan.
  • Composite indexes are left-prefix. An index on (a, b, c) serves queries filtering on a, on a and b, or on all three; classically it does not serve a query filtering only on b. Order the columns by equality-filters first, then the range or sort column. Newer engines can sometimes bend the rule: PostgreSQL 18 added skip scan for multicolumn B-trees, and MySQL 8.0.13 added a limited skip-scan range method. Both probe once per distinct value of the skipped leading column, so they help only when that column has few distinct values. Design for the left-prefix rule and treat skip scan as a bonus.
  • Covering indexes. If the index contains every column the query needs, the database never touches the table — an index-only scan, often several times faster.
  • The planner can be wrong. It chooses from cost estimates based on statistics, and stale statistics or a skewed distribution produce catastrophically bad plans, which is why "read the query plan" is a real skill.
-- The query the product actually runs
SELECT id, body FROM posts
WHERE author_id = 42 AND created_at > now() - interval '7 days'
ORDER BY created_at DESC LIMIT 20;

-- Wrong order: created_at first means every recent post by anyone is scanned
CREATE INDEX ON posts (created_at, author_id);

-- Right: equality column first, then the range/sort column
CREATE INDEX ON posts (author_id, created_at DESC);
-- Now the rows are already sorted within author_id -- the LIMIT stops early,
-- and EXPLAIN ANALYZE shows an index scan with no sort node at all.
Say it like this → "This table is write-heavy, so I want the smallest number of indexes that serve the query patterns — one composite on (author_id, created_at DESC) covers both the filter and the ordering. I'd check EXPLAIN ANALYZE for a sort node; if one appears, the index isn't doing its job."

Read-heavy and write-heavy are different problems

Read-heavy (feeds, catalogs, profiles)Write-heavy (events, telemetry, messages, ledgers)
First moveCache the hot set; reads follow a power lawBatch and buffer writes; append rather than update in place
Second moveRead replicas, with an explicit staleness policy per endpointPartition by key so writes spread across nodes
Indexing postureIndex generously — reads dominateIndex sparingly — every index taxes the hot path
Data modellingDenormalize toward the read shapeKeep writes narrow; derive read models asynchronously
Storage engine that fitsB-tree — reads land in one place, in-place updatesLSM tree — writes go to an in-memory table then flush sequentially; costs read amplification and background compaction I/O
Typical storesPostgres/MySQL plus Redis plus a CDNCassandra, ScyllaDB, RocksDB-backed systems, Kafka as a write buffer

Being able to say "B-tree for read-optimised in-place updates, LSM for write-optimised sequential flushes, and the LSM's price is read amplification plus compaction" is one of the highest-value-per-word things you can put on the whiteboard. It explains a whole category of database choices in one sentence.

Connection pooling, the bottleneck nobody draws

Databases do not accept unlimited connections, and the limit is far lower than people expect. Postgres runs a process per connection, and typical configurations cap out between 100 and 500. The memory per connection is smaller than folklore says (a Postgres core developer measured under 2 MiB with huge pages enabled); the bigger cost is CPU, because the work of taking a snapshot grows with the number of connections, so even idle ones slow the active ones (improved in Postgres 14, but not gone). MySQL uses threads and is cheaper, but the shape is identical. Meanwhile a fleet of 50 app servers each holding a 20-connection pool wants 1,000 connections, and the database falls over — not from query load, but from connection count.

ConceptNumber to quoteConsequence
Useful pool sizeA common starting heuristic is (core count × 2) plus the number of disks that are actually busy, in total across all clients; then load-testBeyond the sweet spot, throughput drops — you have added queueing and context switching, not capacity
Per-connection overheadA few MB of memory at most in Postgres, plus snapshot and scheduling CPU that grows with connection countIdle connections are not free, and thousands of them hurt even when nothing is running
External pooler (PgBouncer and friends)Multiplexes thousands of client connections onto tens of server connectionsTransaction-level pooling breaks session state: session variables, advisory locks, LISTEN/NOTIFY, and long transactions. Named prepared statements work in transaction mode since PgBouncer 1.21 when max_prepared_statements is non-zero; SQL-level PREPARE and EXECUTE are not tracked
Serverless and autoscaled functionsEach instance opens its own connections; scale-out multiplies themA pooler or a data proxy is mandatory, not optional
⚠ "Just raise max_connections" is the wrong instinct It converts a fast failure into a slow one. The database now accepts every connection and services all of them badly, so latency climbs across the board instead of a few clients being rejected. The right answer is a bounded pool plus a pooler in front, and rejecting or queueing excess work at the application edge where you can shed it cheaply.

Where "just use Postgres" is the right senior answer

A candidate who reaches for six specialised stores in a 45-minute design is describing an operational burden, not an architecture. Every additional datastore is another failure mode, another backup and restore story, another consistency boundary, and another thing your on-call has to understand at 3 a.m. One mature relational database covers a startling amount of ground:

  • Document store — JSONB columns with GIN indexes handle schemaless data honestly well
  • Key-value — a two-column table with a primary key is a fine key-value store at any scale a single node handles
  • Search — built-in full-text is genuinely good up to millions of documents before Elasticsearch earns its keep
  • Queue — SELECT ... FOR UPDATE SKIP LOCKED gives you a correct work queue at modest job rates. Each job costs at least three writes (enqueue, claim, complete), so every job per second spends part of your ~5,000 writes/sec budget
  • Geospatial, time-series, vectors — PostGIS, partitioned time ranges, and pgvector all exist and are used in production
  • Analytics — a read replica keeps reporting queries off the primary until data volumes genuinely demand a columnar engine

The point is not that Postgres is always right. The point is that consolidating on one store buys operational simplicity, and simplicity is a legitimate design goal you are allowed to argue for. State the exit conditions and you get the credit without sounding dogmatic.

Reach past Postgres when…Because
Sustained writes pass the ~5,000/sec planning figure (partition, batch, or put a queue in front); at ~20,000+ sustained, you are sharding or queueing nowA partitioned wide-column store (or sharded Postgres) scales writes horizontally by design. Strong hardware with batching goes several times higher on one primary, so measure first
You need active-active writes in multiple regionsSingle-leader replication cannot do it; you need multi-leader, a leaderless store, or distributed SQL like Spanner or CockroachDB
Queries are relevance-ranked free textAn inverted index with proper analysers and scoring is a different data structure
Analytics scan billions of rows over a few columnsRow storage reads columns you don't need; columnar engines are often an order of magnitude or more faster here, depending on the query
Access is a single key at sub-millisecond latency, at very high ratesAn in-memory key-value store avoids disk, planner, and MVCC overhead entirely
The workload is deep, variable-length graph traversalRecursive joins degrade badly past a few hops

Recognizing it in an unseen problem

  • Before choosing a store, write the two or three highest-volume queries as literal key lookups — the key you need is usually the partition key, and it decides the store more than the store decides the key
  • "Transaction", "balance", "inventory", "booking", "must not double-charge" → relational with real ACID, or an explicit idempotency and reconciliation design; do not hand-wave this one
  • "Timeline", "feed", "events", "telemetry", "messages" with huge write volume and partition-scoped reads → wide-column on an LSM engine; expect the follow-up about hot partitions
  • If the prompt implies free-text search or analytics, propose a derived index fed from the source of truth, and be ready to explain how it stays in sync (change data capture, dual writes plus reconciliation, or a periodic rebuild)
  • Any time you propose denormalization, name the write amplification and the repair path in the same breath — proposing it without them is the most common way to lose the data-modelling signal
  • Distinguish "this table is a bottleneck" from "this database is a bottleneck": the first is solved with an index, a cache, or moving one table, and the first is far more often the truth
Before you move on
  • Can you run the six access-pattern questions against a new data type before naming a store?
  • Can you explain why "NoSQL for scale" is a false dichotomy, with a concrete counterexample?
  • Can you name the three costs of denormalization (write amplification, update anomalies, growth) whenever you propose it?
  • Can you explain why composite index column order matters, using the (author_id, created_at) example?
  • Can you say what happens to a database facing 1,000 connections from a 50-server fleet, and how a pooler fixes it?
  • Can you name one anomaly that Repeatable Read still allows, and what Serializable asks of your application code?
Sources and further reading
  • PostgreSQL docs: transaction isolation — default level, what each level permits, and serializable snapshot isolation
  • PostgreSQL 18 release notes — skip scan for multicolumn B-tree indexes
  • MySQL docs: range optimization — the skip scan method added in 8.0.13 and its conditions
  • MongoDB docs: transactions — multi-document ACID transactions and their performance caveats
  • DynamoDB docs: transactions — TransactWriteItems limits, isolation and capacity cost
  • Analyzing the limits of connection scalability in Postgres — per-connection memory is small; snapshot scalability is the real limit
  • PgBouncer configuration — max_prepared_statements and prepared statements in transaction pooling
  • Connection pool sizing guide — the cores-times-two starting heuristic and why smaller pools win
  • PostgreSQL docs: SELECT, locking clause — SKIP LOCKED for queue-like tables
  • OpenAI: scaling PostgreSQL — one primary plus about 50 replicas at millions of queries per second
  • Cockroach Labs: enterprise license update — the 2024 move to a single licence for self-hosted CockroachDB
  • Redis 8.0 adds AGPLv3 — the May 2025 licence change, and the community fork that preceded it

Claims in this chapter were checked against these sources in October 2026. Figures marked "planning figure" are rules of thumb, not guarantees.

✓Practise

5 questions drawn from 8, scored out of 5. This is practice: it runs in your browser, saves nothing and does not change what you have marked read.

B4

Finished reading?

0 of 36 chapters read in this series

→Next stop

  1. B3Previous · B3Scaling: vertical vs horizontal
  2. B4you are here
  3. Next · B5Caching fundamentalsA cache is one bet placed at six distances — and the interesting number is never the hit rate, it's the miss rate.24 min read

01On this page

9 sections · 0%

  1. 01The axis is access pattern, not scale
  2. 02The families, and what each is genuinely good at
  3. 03Transactions: what "ACID" hides
  4. 04The same data, modelled two ways
  5. 05Indexes: what they buy and what they cost
  6. 06Read-heavy and write-heavy are different problems
  7. 07Connection pooling, the bottleneck nobody draws
  8. 08Where "just use Postgres" is the right senior answer
  9. 09Recognizing it in an unseen problem

[] chapters · t top