B4 · 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.
09On this page
- 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
- Read-heavy and write-heavy are different problems
- Connection pooling, the bottleneck nobody draws
- Where "just use Postgres" is the right senior answer
- 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 ask | Pushes you relational | Pushes you non-relational |
|---|---|---|
| Do I know every query up front? | No — the product will invent new ones monthly | Yes — two or three fixed lookups, forever |
| Do I need to combine entities at read time? | Yes — joins across users, orders, items | No — one key returns everything needed |
| Do I need multi-row atomicity? | Yes — transfers, inventory, bookings | Single-key atomicity is enough |
| What is the write rate to one logical key? | Anything a single primary can absorb | Beyond 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 asset | Yes — heterogeneous, sparse, evolving documents |
| How bad is stale or lost data? | Unacceptable — money, medical, legal | Tolerable — 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
| Question | Applied 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 order | Pushes non-relational |
| Do I need to combine entities at read time? | No — a message row is self-contained; the read never joins across conversations | Pushes non-relational |
| Do I need multi-row atomicity? | No — each message insert is independent; nothing needs two messages written together | Pushes 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 id | Pushes non-relational (mildly): no hot key, easy partitioning |
| Does the shape of the data vary per record? | No — sender, text, timestamp is a stable shape | Neutral — 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 acknowledgement | Rules 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
| Family | Data model | Genuine strength | Reach 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
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).
| Normalized | Denormalized | |
|---|---|---|
| Read cost | Joins at query time, planner-dependent | One lookup by key |
| Write cost | One row | Fan-out to every copy, often asynchronously |
| Consistency | Enforced by the database | Your responsibility, and eventual at best |
| Schema change | One migration | Backfill every copy |
| Reach for this when… | Default — until profiling says otherwise | Read/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.
- 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 move | Cache the hot set; reads follow a power law | Batch and buffer writes; append rather than update in place |
| Second move | Read replicas, with an explicit staleness policy per endpoint | Partition by key so writes spread across nodes |
| Indexing posture | Index generously — reads dominate | Index sparingly — every index taxes the hot path |
| Data modelling | Denormalize toward the read shape | Keep writes narrow; derive read models asynchronously |
| Storage engine that fits | B-tree — reads land in one place, in-place updates | LSM tree — writes go to an in-memory table then flush sequentially; costs read amplification and background compaction I/O |
| Typical stores | Postgres/MySQL plus Redis plus a CDN | Cassandra, 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.
| Concept | Number to quote | Consequence |
|---|---|---|
| Useful pool size | A common starting heuristic is (core count × 2) plus the number of disks that are actually busy, in total across all clients; then load-test | Beyond the sweet spot, throughput drops — you have added queueing and context switching, not capacity |
| Per-connection overhead | A few MB of memory at most in Postgres, plus snapshot and scheduling CPU that grows with connection count | Idle 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 connections | Transaction-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 functions | Each instance opens its own connections; scale-out multiplies them | A 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 now | A 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 regions | Single-leader replication cannot do it; you need multi-leader, a leaderless store, or distributed SQL like Spanner or CockroachDB |
| Queries are relevance-ranked free text | An inverted index with proper analysers and scoring is a different data structure |
| Analytics scan billions of rows over a few columns | Row 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 rates | An in-memory key-value store avoids disk, planner, and MVCC overhead entirely |
| The workload is deep, variable-length graph traversal | Recursive 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.
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.
Finished reading?
0 of 36 chapters read in this series