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.
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. Most NoSQL systems removed those features up front, which is exactly what lets them partition automatically. 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 |
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 |
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. Five indexes on a hot table can cut write throughput by half or more, 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; it does not serve a query filtering only on b. Order the columns by equality-filters first, then the range or sort column.
- 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.
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 forks a process per connection at roughly 5-10 MB of overhead each, and typical configurations cap out between 100 and 500. 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 | Roughly 2-4x the database's CPU core count, in total across all clients | Beyond that, throughput drops — you have added queueing and context switching, not capacity |
| Per-connection overhead | ~5-10 MB in Postgres | Idle connections consume real memory that the page cache wanted |
| External pooler (PgBouncer and friends) | Multiplexes thousands of client connections onto tens of server connections | Transaction-level pooling breaks session state: prepared statements, session variables, advisory locks, and long transactions |
| Serverless and autoscaled functions | Each instance opens its own connections; scale-out multiplies them | A pooler or a data proxy is mandatory, not optional |
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, comfortably into the thousands of jobs per second
- 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 exceed what one primary can absorb (roughly tens of thousands per second, workload dependent) | A partitioned wide-column store scales writes horizontally by design |
| 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 10-100x faster here |
| 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