Course · 06

How does a database keep data correct and still find it fast?

Transactions, isolation, indexes and storage engines, then the SQL vs NoSQL choice. Facts are checked against the PostgreSQL 18 docs (current on 2026-10-25), MySQL 8.4, Cassandra, DynamoDB and MongoDB docs.

TL;DR

A database is correct because of transactions (ACID) and fast because of indexes; every speed trick costs something somewhere else.

  • Isolation levels trade safety for speed. PostgreSQL defaults to Read Committed; MySQL InnoDB to Repeatable Read. Of the four levels, only Serializable stops write skew.
  • The WAL makes commits durable with one sequential write, so data pages can be written later.
  • B-trees are good for reads and ranges; LSM-trees are good for heavy writes; hash indexes only do equality.
  • Indexes speed reads but slow every insert, update and delete.
  • Pick SQL for relations and transactions; pick NoSQL when one access pattern needs huge scale.
4
SQL isolation levels, 4 anomalies (PG Table 13.1)
8kB
usual PostgreSQL page size
100
typical default max_connections in PostgreSQL
16MiB
max MongoDB document size

—Words you need

Each jargon word once, in plain words glossary

Transaction
Several steps that succeed or fail as one.
Isolation
How much running transactions can see of each other.
Anomaly
A wrong result caused by transactions overlapping.
MVCC
Keep old row versions so readers never wait for writers.
Snapshot
A frozen view of the data at one moment.
Index
A sorted lookup structure, like a book's index.
WAL
Write-ahead log: write the change note before the change.
LSM-tree
Buffer writes in memory, flush sorted files, merge later.
Compaction
Merging many sorted files into fewer, bigger ones.
Normalization
Storing each fact once to avoid conflicting copies.
Partition key
The field that decides which machine stores a row.
Connection pool
A set of open DB connections the app reuses.

Transactions

01The relational model

Tables, rows, keys, and joins core

A relation is a table. Each row is one fact. Each column has one type. A primary key names a row uniquely. A foreign key points at another table's primary key. A join combines tables on those keys at query time.

Two tables linked by a foreign key
CREATE TABLE users  (id bigint PRIMARY KEY, email text UNIQUE NOT NULL);
CREATE TABLE orders (id bigint PRIMARY KEY,
                     user_id bigint REFERENCES users(id),
                     total numeric(10,2), created_at timestamptz);
SELECT u.email, o.total FROM orders o JOIN users u ON u.id = o.user_id;
In everyday words
A set of spreadsheets where each sheet links to others by an ID number, and the database refuses links to IDs that do not exist.
Actually
The model comes from E. F. Codd, "A Relational Model of Data for Large Shared Data Banks", Communications of the ACM, 1970. Its big idea: you say what data you want, and the database plans how to get it. That planner is what EXPLAIN shows (section 09). The schema plus constraints (NOT NULL, UNIQUE, REFERENCES) let the database reject bad data before it is stored.

Relational is the default choice when data has many relationships and you need ad-hoc queries.

Source: Codd 1970, CACM · PostgreSQL tutorial: concepts

02ACID, one letter at a time

What a transaction promises interview favourite

LetterPlain meaningBank-transfer exampleHow the DB does it
AtomicityAll steps happen, or noneDebit and credit both happen, or neitherRollback on error; WAL replay ignores uncommitted work
ConsistencyRules hold before and afterBalance never goes below 0 if a CHECK says soConstraints, foreign keys, triggers; partly the app's job
IsolationRunning transactions do not see each other's half-done workA report never sees money "in flight"MVCC snapshots and locks; strength depends on the level
DurabilityOnce committed, it survives a crashThe ATM says "done", power fails, money still movedWAL flushed to disk before commit returns
In everyday words
A shop till: the sale either fully rings up or is voided (A), the drawer total must match the receipts (C), two cashiers do not mix up each other's sales (I), and the receipt roll is kept after a power cut (D).
Actually
The PostgreSQL tutorial says a transaction "bundles multiple steps into a single, all-or-nothing operation", and that "all the updates made by a transaction are logged in permanent storage (i.e., on disk) before the transaction is reported complete". Note: the "C" is the weakest letter. The database only enforces the rules you declare; business rules in code are your job. Isolation is a dial, not a yes/no (next section).

When an interviewer says "ACID", the real follow-up is "which isolation level?". Be ready for it.

Source: PostgreSQL tutorial: transactions

03Isolation levels and the anomalies they allow

The four standard levels, and what goes wrong at each interview favourite

LevelDirty readNon-repeatable readPhantom readSerialization anomaly
Read uncommittedAllowed, but not in PGPossiblePossiblePossible
Read committed (PG default)Not possiblePossiblePossiblePossible
Repeatable readNot possibleNot possibleAllowed, but not in PGPossible
SerializableNot possibleNot possibleNot possibleNot possible
AnomalyWhat happens
Dirty readYou read data another transaction has not committed yet.
Non-repeatable readYou read a row twice and get different values, because someone committed in between.
Phantom readYou run the same WHERE query twice and get a different set of rows.
Lost updateTwo transactions read, change and write the same row; one write wipes out the other.
Write skewTwo transactions read overlapping data, each writes something different, and together they break a rule neither broke alone.
A lost update between two transactions Transaction A and transaction B both read stock 10, both compute 9 and both write 9. The final stock is 9 although two items were sold, so one update is lost. READ = 10 READ = 10 WRITE 9 WRITE 9 Txn Asells 1 Databasestock = 10 Txn Bsells 1 Stock = 9should be 8 LEGEND Transaction Wrong result Time flows down
Both read before either wrote. The second write silently erases the first.
In everyday words
Two people edit the same shared document offline. Whoever saves last wins, and the other person's edits silently vanish.
Actually
The first table is PostgreSQL Table 13.1. "Lost update" and "write skew" are not columns in it; they come from the research literature (Berenson et al. 1995, and Kleppmann's DDIA). In PostgreSQL: Read Uncommitted behaves like Read Committed; Repeatable Read is snapshot isolation and blocks phantoms; a Repeatable Read transaction that updates a row changed by a concurrent commit fails with could not serialize access due to concurrent update, which prevents lost updates. Write skew is shown by PG's mytab example (two transactions sum class 1 and class 2 and insert into the other): "If either transaction were running at the Repeatable Read isolation level, both would be allowed to commit". Serializable (SSI) rolls one back with SQLSTATE 40001, and the app must retry. MySQL InnoDB defaults to Repeatable Read and uses next-key/gap locks for locking reads.

Read Committed is the common default, and it allows lost updates. Fix with atomic UPDATE ... SET x = x - 1, SELECT FOR UPDATE, a version column, or Serializable + retry.

Source: PostgreSQL Transaction Isolation · MySQL 8.4 InnoDB isolation levels

04MVCC basics

Keep old versions so readers and writers do not block each other deep dive

MVCC means multi-version concurrency control. An update does not overwrite a row in place. It writes a new version and marks the old one as ended. Each transaction reads the versions that were committed when its snapshot was taken.

LevelWhen the snapshot is taken
Read committedNew snapshot for every statement
Repeatable read / SerializableOne snapshot for the whole transaction (at its first statement)
In everyday words
A wiki with page history. Readers see the version from when they opened the page; editors save a new version without kicking readers out.
Actually
PostgreSQL docs: "Each SQL statement sees a snapshot of data (a database version) as it was some time ago", and "reading never blocks writing and writing never blocks reading". The cost is dead row versions that must be cleaned up (in PostgreSQL by VACUUM), plus extra storage. InnoDB likewise reads from a snapshot "established by the first read" under Repeatable Read.

MVCC is why a long report query does not freeze your checkout page, and why long-open transactions bloat tables.

Source: PostgreSQL MVCC intro · MySQL InnoDB isolation

05Locking: pessimistic vs optimistic

Lock first, or check at the end core

StyleHowGood whenCost
PessimisticSELECT ... FOR UPDATE locks rows until commitConflicts are common (hot seats, stock)Waiting, deadlocks
OptimisticRead a version; UPDATE ... WHERE id=? AND version=?; 0 rows = conflict, retryConflicts are rareRetries under contention
Atomic statementUPDATE stock SET n = n - 1 WHERE id=? AND n > 0Simple countersOnly fits simple logic
BEGIN;
SELECT * FROM seats WHERE id = 7 FOR UPDATE;          -- others wait here
UPDATE seats SET booked_by = 42 WHERE id = 7;
COMMIT;
-- job queue: many workers, no waiting on each other
SELECT id FROM jobs WHERE state='new' LIMIT 10 FOR UPDATE SKIP LOCKED;
In everyday words
Pessimistic: lock the bathroom door. Optimistic: walk in and say sorry if someone is there.
Actually
FOR UPDATE blocks other UPDATE, DELETE and locking SELECTs on those rows until the transaction ends. NOWAIT errors instead of waiting; SKIP LOCKED skips locked rows, which the docs say suits "a queue-like table" but gives "an inconsistent view". Deadlocks: two transactions lock rows in opposite order; PostgreSQL detects it and aborts one. Advice: always lock in the same order and retry. Martin Fowler names the optimistic pattern "Optimistic Offline Lock": detect a conflict and roll back.

Default to an atomic UPDATE; use a version column for user edits; use FOR UPDATE only for short, hot critical sections.

Source: PostgreSQL explicit locking · PostgreSQL SELECT locking clause · Fowler, Optimistic Offline Lock

Storage and indexes

06Write-ahead log (WAL)

How commits are durable and still fast deep dive

Write-ahead log: log first, pages later An UPDATE first writes a record to the WAL file, which is flushed to disk at commit. It also changes the page in shared buffers in memory. The data files on disk are updated later. 1 LOG FIRST 2 EDIT PAGE 3 LATER Your UPDATEthen COMMIT WAL fileappend + fsync Page in RAMshared buffers Data filestables, indexes LEGEND Must hit disk at commit Memory Written later
Commit waits for one sequential log write, not for random page writes.

Changing a table touches pages scattered all over the disk. Writing them all at each commit would be slow. So the database first appends a small change record to the WAL and flushes only that. The changed pages stay in memory and reach the data files later, at a checkpoint.

In everyday words
A chef shouts the order into a voice recorder before cooking. If the kitchen burns, the recording tells you what to cook again.
Actually
PostgreSQL: "changes to data files … must be written only after those changes have been logged, that is, after WAL records describing the changes have been flushed to permanent storage." After a crash, the WAL is replayed (roll-forward / REDO). The WAL is written sequentially, and "one fsync of the WAL file may suffice to commit many transactions". Archived WAL also enables point-in-time recovery and feeds replication (page 07). Redis AOF and Cassandra's commit log are the same idea.

"Durable" means "in the log on disk", not "in the table file". Replication and recovery both read the log.

Source: PostgreSQL WAL intro

07Indexes: B-tree vs hash vs LSM-tree

Three shapes, three trade-offs interview favourite

Finding key 42 in a B-tree index The root page splits keys at 40 and 80. Key 42 goes to the middle leaf, which holds keys 40 to 79 and a pointer to the table row on its heap page. ROW POINTER Root pagesplit at 40, 80 Leaf 1keys 1-39 Leaf 2keys 40-79 Leaf 3keys 80+ Table rowheap page LEGEND Signpost page Leaf page Where 42 lives
Each step down cuts the search space. A page holds many keys (8 kB pages in PostgreSQL), so trees stay shallow.

A B-tree keeps keys sorted in pages. Each page points to child pages. You walk from the root to a leaf. Because keys are sorted, ranges like BETWEEN and ORDER BY are cheap.

LSM-tree write path A write goes to an append-only commit log for safety and into a sorted memtable in RAM. Full memtables are flushed as immutable SSTables, and compaction merges SSTables into larger ones. FLUSH COMPACTION Writeput(k, v) Commit logappend, disk Memtablesorted, RAM SSTablesimmutable Bigger runmerged, sorted LEGEND Memory Disk, never edited Background merge
Writes only append. Cleaning up happens later, in the background.

An LSM-tree (log-structured merge-tree) never edits files in place. Writes go to a log and a sorted in-memory table. Full tables are flushed as sorted, immutable files called SSTables. Compaction merges them in the background. Reads may check several files, so engines use Bloom filters to skip files.

IndexGood atWeak atSeen in
B-treeEquality and ranges (< <= = >= >, BETWEEN, sort); steady read latencyRandom writes update pages in placePostgreSQL default, MySQL InnoDB
HashOnly =No ranges, no sortPostgreSQL USING HASH
LSM-treeVery high write rates (sequential appends)Reads may touch many files; compaction uses I/OCassandra, RocksDB
In everyday words
B-tree: a library where every new book is shelved in the right spot at once. LSM: drop new books in a box, sort the box later, and merge boxes into shelves at night.
Actually
PostgreSQL: "By default, the CREATE INDEX command creates B-tree indexes", and hash indexes "can only handle simple equality comparisons". Tables and indexes are stored as pages "usually 8 kB". The LSM-tree comes from O'Neil, Cheng, Gawlick and O'Neil, Acta Informatica, 1996. Cassandra: commit log for durability, memtable in memory, SSTables are "immutable", compaction "combine[s] multiple SSTables into one", and Bloom filters speed reads. RocksDB: leveled compaction has more write amplification and less space amplification; tiered (universal) compaction is the reverse.

Read-heavy with ranges: B-tree. Write-heavy firehose (logs, metrics, events): LSM. Exact lookup only: hash (or just a B-tree).

Source: PostgreSQL index types · PG page layout · Cassandra storage engine · RocksDB compaction · O'Neil et al. 1996

08Composite and covering indexes, and when indexes hurt

The leftmost-prefix rule interview favourite

CREATE INDEX idx_orders ON orders (user_id, created_at);
-- uses the index well:  WHERE user_id = 42
-- uses the index well:  WHERE user_id = 42 AND created_at > now() - interval '7 days'
-- poor fit:             WHERE created_at > now() - interval '7 days'   -- leading column missing
CREATE INDEX idx_cover ON orders (user_id) INCLUDE (total);  -- covering: SELECT total WHERE user_id=?
IdeaRule
Leftmost prefixEquality on leading columns, then a range on the next column, narrows the scan. Skipping the first column mostly wastes the index.
Column orderPut equality columns first, the range or sort column last.
Covering indexIf every column the query needs is in the index, the DB can answer from the index alone (index-only scan).
Cost of indexesEvery insert, update and delete must also update each index.
In everyday words
A phone book sorted by last name, then first name. Easy to find "Shah, Priya". Useless for "everyone named Priya".
Actually
PostgreSQL: a multicolumn B-tree is "most efficient when there are constraints on the leading (leftmost) columns"; "equality constraints on leading columns, plus any inequality constraints on the first column that does not have an equality constraint" limit the scan. "Indexes with more than three columns are unlikely to be helpful". Index-only scans also need the heap page to be marked all-visible in the visibility map. On cost: "the system has to keep it synchronized with the table. This adds overhead to data manipulation operations … indexes that are seldom or never used in queries should be removed."

Design indexes from your top queries, not from your columns. Drop the ones nobody uses.

Source: PG multicolumn indexes · PG index-only scans · PG indexes intro

09Query planning and EXPLAIN

Ask the database how it will run your query core

EXPLAIN SELECT * FROM tenk1;
 Seq Scan on tenk1  (cost=0.00..445.00 rows=10000 width=244)
EXPLAIN ANALYZE SELECT * FROM tenk1 WHERE unique1 < 10;   -- really runs it
You seeIt means
Seq ScanReads the whole table. Fine for small tables or when most rows match.
Index ScanWalks the index, then fetches each row. Good for few rows.
Bitmap Heap ScanCollects row locations from the index, sorts them, then reads pages in order. Good for many rows.
cost=a..bPlanner's estimate: startup cost .. total cost (arbitrary units).
rows= estimated vs actualBig gaps mean stale statistics; run ANALYZE.
Actually
EXPLAIN shows a tree of plan nodes with estimated cost, rows and width. EXPLAIN ANALYZE "actually executes" the query and adds real times and row counts, so be careful with it on UPDATE or DELETE (wrap it in a transaction you roll back).

In an interview, "I'd check EXPLAIN ANALYZE for a Seq Scan on a big table" is the right first step for a slow query.

Source: PostgreSQL Using EXPLAIN

Modelling and choosing

10Normalization vs denormalization

Store each fact once, or copy it for speed core

FormRule (textbook)Breaks it
1NFEach cell holds one value; no repeating groupsphones = "111,222"
2NF1NF, and no column depends on only part of a composite key(order_id, product_id) → product_name
3NF2NF, and no column depends on another non-key columnorders.user_city copied from users
ChoiceWinCost
NormalizeOne place to update; no conflicting copies; flexible queriesJoins at read time
DenormalizeFast reads, no joins; fits NoSQL and read-heavy viewsEvery copy must be updated; risk of drift
In everyday words
Normalized: one contacts list, everyone links to it. Denormalized: each form has the address written on it, so you must fix every form when someone moves.
Actually
Normal forms are textbook definitions that grew from Codd's work; this page states them in simplified form. Cassandra's own docs describe the opposite approach on purpose: data "is modeled around specific queries", there are no joins, and duplication is "known as denormalization". MongoDB's rule of thumb: "Data that's accessed together should be stored together."

Normalize the source of truth. Denormalize read models (caches, search indexes, materialized views) that you can rebuild.

Source: Codd 1970 · Cassandra data modeling · MongoDB data modeling

11NoSQL families

Four shapes of non-relational database core

Key-value · DynamoDB
Get and put by key, at any scale
Primary key = partition key, or partition + sort key. The partition key is hashed to pick the partition; items with the same partition key are stored sorted by sort key. Up to 20 GSIs (default) and 5 LSIs per table. Use for carts, sessions, user profiles.
Document · MongoDB
JSON-like documents with a flexible schema
Embed data read together; reference data read apart. Max BSON document is 16 MiB. Use for catalogs, content, apps whose shape changes often.
Wide-column · Cassandra
Partitions of sorted rows, built for writes
Partition key spreads data across nodes; clustering columns sort rows inside a partition. One table per query, no joins. Use for time series, messaging, IoT.
Graph · Neo4j
Nodes and relationships as first-class data
Stores "nodes, relationships, and properties instead of in tables". Relationships are stored with the data, so deep traversals avoid big joins. Use for social graphs, fraud rings, recommendations.
Actually
DynamoDB: "DynamoDB uses the partition key's value as input to an internal hash function", and other than the key "the table is schemaless". MongoDB limits: "The maximum BSON document size is 16 mebibytes." Neo4j's claim that traversals beat joins is its own marketing; it holds for many-hop queries, not for simple lookups. Quotas change; numbers here are as of the docs on 2026-10-25.

Name the family by the access pattern: by key → KV; whole object → document; huge time-ordered writes → wide-column; "friends of friends" → graph.

Source: DynamoDB core components · DynamoDB quotas · MongoDB limits · Cassandra data modeling · Neo4j graph database

12SQL vs NoSQL, and BASE

A decision, not a religion decision

NeedLeanWhy
Money, inventory, multi-row invariantsSQLACID transactions across rows and tables
Ad-hoc queries, reporting, joinsSQLPlanner + indexes handle new questions
Known access pattern at huge scaleNoSQL (KV / wide-column)Partitioned by key, scales out horizontally
Very high write rate (events, metrics)Wide-column / LSM engineSequential writes, compaction later
Flexible, nested, changing shapeDocumentNo schema migration for each new field
Deep relationshipsGraphTraversal instead of many self-joins
Unsure, small to mediumPostgreSQLDoes most things well; easy to change later
In everyday words
SQL is a bank vault with strict rules. NoSQL is a fast warehouse where you must say up front which shelf you will look on.
Actually
BASE = Basically Available, Soft state, Eventually consistent. It was named by Dan Pritchett (eBay) in "BASE: An Acid Alternative", ACM Queue, 2008: trade some consistency for availability in partitioned systems. "Eventually consistent" means replicas can disagree for a while but converge if writes stop. The lines are blurry today: MongoDB has multi-document transactions and DynamoDB has transactions; many SQL systems shard. The CAP side of this is on page 08.

Start from the access patterns and consistency needs, then pick. "NoSQL scales, SQL doesn't" is a weak answer.

Source: Pritchett, BASE: An Acid Alternative (ACM Queue) · MongoDB data modeling

App side

13Connection pooling

Reuse a few open connections core

A connection pool between app and database Three app workers borrow connections from one pool. The pool keeps a small set of open database connections and reuses them, so the database sees few connections. REUSE Worker 1request Worker 2request Worker 3request Poolborrow, return Databasefew conns LEGEND App work Keeps connections open Database
Opening a connection is slow. Borrowing one that is already open is fast.

Opening a DB connection costs a TCP handshake, TLS, auth, and (in PostgreSQL) a server process. A pool keeps some connections open and lends them out. It also caps how many connections hit the database.

PgBouncer modeA server connection is held forNote
SessionThe whole client connection"Most polite"; all features work
TransactionOne transactionBest reuse; session state (e.g. SET) does not carry over
StatementOne statementMulti-statement transactions are not allowed
In everyday words
A taxi rank: a few taxis serve many passengers, instead of every passenger buying a car.
Actually
PostgreSQL's max_connections default "is typically 100 connections". With 50 app servers × 20 threads you would want 1,000, so you need a pool in each app (e.g. HikariCP) and often a shared pooler such as PgBouncer, which needs about 2 kB per client connection. Pool size should match what the DB can run in parallel, not the number of app threads.

More connections is not more throughput. Size the pool to the database, and queue in the app.

Source: PG connection settings · PgBouncer features

14The N+1 query problem

One query for the list, one more per item interview favourite

# N+1: 1 query for 10 books + 10 queries for authors = 11 queries
books = Book.limit(10)
books.each { |b| puts b.author.last_name }
# Eager loading: 2 queries total
books = Book.includes(:author).limit(10)
# SELECT authors.* FROM authors WHERE authors.id IN (1,2,...,10)
In everyday words
Going to the shop once per item on your list, instead of once with the whole list.
Actually
The Rails guide shows exactly this: 10 books cost 11 queries, and includes cuts it to 2 using an IN list (or a LEFT OUTER JOIN). Every ORM has the same trap (JPA lazy loading, Django select_related / prefetch_related). Each query pays a network round trip, so 1,000 items = 1,000 round trips. Detect it by counting queries per request in logs or APM.

Batch with JOIN or IN, or use a DataLoader in GraphQL. Count queries per request in tests.

Source: Rails guide: Active Record Query Interface (N + 1)

15Pagination at the database level

OFFSET is easy; keyset is fast core

-- OFFSET: page 1000 still reads and throws away 20,000 rows
SELECT * FROM posts ORDER BY created_at DESC, id DESC LIMIT 20 OFFSET 20000;
-- Keyset (cursor): start after the last row you saw
SELECT * FROM posts
WHERE (created_at, id) < (:last_created_at, :last_id)
ORDER BY created_at DESC, id DESC LIMIT 20;
MethodSpeed on deep pagesStable when rows are added?Jump to page 57?
LIMIT / OFFSETSlower the deeper you goNo: rows shift, you see duplicates or skipsYes
Keyset / cursorSame speed at any depth (with an index on the sort keys)YesNo, only next/previous
Actually
PostgreSQL: "The rows skipped by an OFFSET clause still have to be computed inside the server; therefore a large OFFSET might be inefficient." Also, without an ORDER BY that gives a unique order, LIMIT/OFFSET pages "will give inconsistent results". Add a unique tie-breaker like id to the sort.

Infinite scroll and APIs: keyset with an opaque cursor. Admin tables with page numbers: OFFSET is fine for small data.

Source: PostgreSQL LIMIT and OFFSET

Interview prep

—When to use what

Situation → pick → why decision table

SituationPickWhy
Decrement stock safelyAtomic UPDATE ... SET n = n - 1 WHERE n > 0No read-modify-write race
User edits a form for minutesOptimistic lock (version column)No long-held locks
Short hot critical sectionSELECT ... FOR UPDATEOthers wait briefly instead of failing
Rule spans several rows ("at least one doctor on call")Serializable + retry, or lock the rows involvedStops write skew
Worker queue in PostgresFOR UPDATE SKIP LOCKEDWorkers do not block each other
Query filters on a then sorts by bIndex on (a, b)Leftmost prefix, sorted output
Hot query reads 2 columnsCovering index (INCLUDE)Index-only scan
Write-heavy event firehoseLSM store (Cassandra / RocksDB)Sequential writes
Many app servers, one PostgresApp pool + PgBouncer (transaction mode)Stay under max_connections
Infinite scrollKeyset paginationFast at any depth, stable

—Common mistakes

What weak answers say traps

Trap 1
"ACID means no concurrency bugs"
At Read Committed, lost updates and write skew still happen.
Trap 2
"Add an index on every column"
Each index slows writes and uses space; single-column indexes do not combine like a composite.
Trap 3
Index (a, b) for a query on b only
Leading column missing; the index is a poor fit.
Trap 4
Serializable without retry code
Serialization failures (40001) are normal; the app must retry.
Trap 5
Huge pool sizes
Thousands of connections overload the DB; queue in the app instead.
Trap 6
Picking NoSQL "because it scales"
Without known access patterns, you lose joins and transactions for nothing.

—Interview questions

21 questions, easy to design-level, with model answers practice

1. Explain ACID with one example. easy
Show answer
Take a transfer of 100 from A to B. Atomicity: debit and credit both happen or neither; on error the DB rolls back. Consistency: declared rules hold, e.g. a CHECK (balance >= 0) or foreign keys; the app owns business rules. Isolation: another transaction never sees A debited but B not yet credited; how strict depends on the isolation level. Durability: once commit returns, the WAL record is on disk, so a crash right after does not undo it. Follow-up: "Which letter is weakest?" C, because the DB only enforces what you declare; and I is configurable.
2. What are the four isolation levels and what does each allow? easy
Show answer
Read Uncommitted allows dirty reads; Read Committed blocks dirty reads but allows non-repeatable reads and phantoms; Repeatable Read also blocks non-repeatable reads, allows phantoms by the standard; Serializable blocks all, including serialization anomalies. Engines differ: PostgreSQL's Read Uncommitted acts like Read Committed, and its Repeatable Read is snapshot isolation that also blocks phantoms (Table 13.1). Defaults: PostgreSQL Read Committed, MySQL InnoDB Repeatable Read. Higher levels cost more aborts or locking. Follow-up: name an anomaly that Repeatable Read still allows in Postgres: write skew.
3. B-tree vs hash index? easy
Show answer
A B-tree keeps keys sorted in pages, so it supports equality and ranges (<, BETWEEN), prefix LIKE, and gives rows in order for ORDER BY. It's PostgreSQL's default. A hash index stores a 32-bit hash of the value, so it supports only =; no ranges, no sorting. In practice a B-tree is almost always the right choice; hash can be a bit smaller for long values with pure equality lookups. Follow-up: what about LSM-trees? They are a storage design for write-heavy engines, not a Postgres index type.
4. What is MVCC and why does it help? easy
Show answer
Multi-version concurrency control keeps several versions of a row. An update writes a new version; readers keep reading the version visible to their snapshot. So reads never block writes and writes never block reads (PostgreSQL docs). In Read Committed each statement gets a fresh snapshot; in Repeatable Read the whole transaction uses one. Cost: dead versions pile up and must be cleaned (VACUUM in Postgres, purge in InnoDB), and long-running transactions stop that cleanup and bloat tables. Writers still conflict with writers on the same row.
5. What is a write-ahead log and why is it fast? easy
Show answer
Before changing data files, the DB appends a record of the change to the WAL and flushes the WAL to disk at commit. The changed pages stay in memory and are written later at checkpoints. It's fast because one sequential write replaces many random page writes, and one fsync can commit many transactions together (group commit). After a crash, replaying the WAL redoes committed changes. The same log powers replication and point-in-time recovery. Follow-up: Redis AOF and Cassandra commit log are the same pattern.
6. Two users buy the last item at the same time. How do you stop overselling? medium
Show answer
The bug is a lost update: both read stock 1, both write 0. Best fix: one atomic statement, UPDATE items SET stock = stock - 1 WHERE id = ? AND stock > 0, and check the affected row count; 0 means sold out. The row lock taken by the update serializes the two buyers. Alternatives: SELECT ... FOR UPDATE then update, inside one transaction; or optimistic locking with a version column and retry. In PostgreSQL Repeatable Read, the second updater fails with "could not serialize access due to concurrent update". At large scale: reserve stock in Redis with DECR, then confirm in the DB.
7. Explain write skew with an example. Which level prevents it? medium
Show answer
Rule: at least one doctor must be on call. Two doctors are on call. Each, in their own transaction, checks "count on call ≥ 2" (true), then takes themselves off call. Both commit; now zero are on call. Neither transaction changed a row the other changed, so row-level conflict checks don't fire. Snapshot isolation (PG Repeatable Read) allows it; PostgreSQL's own mytab example shows both commit at Repeatable Read. Serializable (SSI) detects the read/write dependency and aborts one with 40001. Other fixes: SELECT ... FOR UPDATE on the rows read, or a constraint that encodes the rule.
8. Optimistic vs pessimistic locking: when each? medium
Show answer
Pessimistic: lock rows up front with SELECT ... FOR UPDATE; others wait. Good when conflicts are frequent and the critical section is short, e.g. seat booking. Risks: waiting, deadlocks, locks held across slow code. Optimistic: read a version number, then UPDATE ... WHERE id = ? AND version = ?; if 0 rows changed, someone else won, so reload and retry or show an error. Good when conflicts are rare and the user takes minutes (editing a form), since nothing is held. Under heavy contention optimistic causes retry storms. Fowler calls it "Optimistic Offline Lock".
9. You have an index on (user_id, created_at). Which queries use it? medium
Show answer
It works well for WHERE user_id = ?, and for WHERE user_id = ? AND created_at > ?, and for WHERE user_id = ? ORDER BY created_at DESC LIMIT 20, since rows come out already sorted. It's a poor fit for WHERE created_at > ? alone because the leading column has no condition; PostgreSQL may still scan the whole index but it's not efficient. Rule from the docs: equality on leading columns plus a range on the next column narrows the scan. If I also need total, adding INCLUDE (total) enables an index-only scan.
10. When do indexes hurt? medium
Show answer
Every index must be updated on each insert, delete, and on updates to its columns, so write-heavy tables slow down with each extra index; PostgreSQL also notes indexes can block HOT updates. They use disk and memory, competing with data for cache. They don't help low-selectivity filters (e.g. a boolean where most rows match), where a sequential scan is cheaper. And unused or duplicate indexes are pure cost; the docs say to drop them. I find them via usage stats (pg_stat_user_indexes) and keep indexes driven by real top queries.
11. B-tree vs LSM-tree storage engines? medium
Show answer
B-tree engines (Postgres, InnoDB) update fixed-size pages in place; reads are predictable, one path from root to leaf, good for read-heavy and range queries; random writes cost page writes plus WAL. LSM engines (Cassandra, RocksDB) append to a commit log and a memtable, flush immutable sorted SSTables, and compact them later. Writes are sequential and very fast; reads may check several SSTables (Bloom filters help) and compaction uses background I/O. Leveled compaction: lower space, more write amplification; tiered: the reverse. Pick LSM for write-heavy time series and logs.
12. A query got slow. How do you debug it? medium
Show answer
Run EXPLAIN (ANALYZE, BUFFERS) on it with real parameters. Look for a Seq Scan on a large table, a big gap between estimated and actual rows (stale stats → run ANALYZE), nested loops over many rows, or a sort spilling to disk. Then: add or fix an index matching the WHERE and ORDER BY (leftmost prefix), rewrite to avoid functions on indexed columns, add a covering index, or paginate with keyset. Check for lock waits and N+1 patterns in the app. Measure again after each change.
13. What is the N+1 problem and how do you fix it? medium
Show answer
You run 1 query to load N parents, then 1 query per parent to load its children: N+1 round trips. The Rails guide shows 10 books → 11 queries. With 1 ms per round trip, 1,000 items add a second. Fix: eager loading (includes, select_related, JPA fetch join) which uses a JOIN or one IN (...) query, giving 2 queries. In GraphQL, a DataLoader batches lookups per request. Detect it by logging query count per request and failing tests above a threshold.
14. Why use a connection pool, and how big should it be? medium
Show answer
Opening a connection costs a TCP and TLS handshake, auth, and in Postgres a new backend process; reusing open ones removes that from each request. The pool also caps load: Postgres max_connections is typically 100. Pool size should match what the DB can execute in parallel (roughly its cores and disk), not app threads; extra requests should queue in the app. With many app instances, add PgBouncer in transaction mode so thousands of clients share a few server connections. Caveat: transaction mode breaks session state like SET and session-level advisory locks.
15. OFFSET vs keyset pagination? medium
Show answer
LIMIT 20 OFFSET 20000 makes the server compute and discard 20,000 rows (PostgreSQL docs), so deep pages are slow, and new rows shift pages, causing duplicates or skips. Keyset pagination remembers the last row's sort key: WHERE (created_at, id) < (:t, :id) ORDER BY created_at DESC, id DESC LIMIT 20. With an index on those columns every page costs the same and results are stable. Downside: no "jump to page 57". I always add a unique tie-breaker (id) and expose an opaque cursor in the API.
16. Normalize or denormalize? medium
Show answer
Normalize the source of truth: each fact stored once (3NF), so updates touch one place and data can't disagree; joins pay at read time. Denormalize when reads dominate and the access pattern is known: copy fields into the read model to avoid joins, at the cost of updating every copy. Cassandra and DynamoDB designs are denormalized on purpose: one table per query. A common hybrid: normalized Postgres for writes, plus denormalized caches, search indexes or materialized views rebuilt from it (often via CDC). Always say how copies stay in sync.
17. SQL or NoSQL for a new product? hard
Show answer
I start from access patterns and consistency needs. If there are many entities with relationships, ad-hoc queries, and invariants across rows (orders, payments, inventory), I pick a relational DB, usually Postgres, and scale with read replicas, caching, and later sharding. I'd pick DynamoDB/Cassandra when the access pattern is simple and known (get by key, time-ordered per partition) and volume is huge or write-heavy; document DBs for nested, changing shapes; graph for multi-hop relationship queries. Often both: Postgres for the core, a KV or wide-column store for a hot path like sessions or event logs.
18. Design the data model for a chat app's messages at scale. design
Show answer
Main query: "latest 50 messages in conversation X, then older". That's one partition per conversation, sorted by time: a wide-column table (Cassandra) with partition key conversation_id and clustering column message_id (time-ordered, e.g. a time-based UUID) descending. Writes are append-heavy, which suits LSM. Pagination is keyset on message_id. To avoid giant partitions for busy groups, add a time bucket to the partition key: (conversation_id, month). User and membership data stay in a relational DB. Unread counts in Redis. Follow-up: search goes to a separate index.
19. What does BASE mean, and how does it relate to ACID? medium
Show answer
Basically Available, Soft state, Eventually consistent. Dan Pritchett of eBay named it in ACM Queue (2008) as an alternative to ACID with two-phase commit across partitioned data: keep serving even during partitions, accept that state may be temporarily inconsistent, and let replicas converge. ACID gives strong guarantees per transaction; BASE gives availability and scale. In practice it means designing for retries, idempotency, and reconciliation, e.g. updating an order and a counter separately via a queue. Many modern stores sit in between, offering tunable consistency.
20. Build a job queue on PostgreSQL with many workers. design
Show answer
Table jobs(id, state, run_at, attempts, payload) with an index on (state, run_at). Each worker runs, in one transaction: SELECT id FROM jobs WHERE state='new' AND run_at <= now() ORDER BY run_at LIMIT 10 FOR UPDATE SKIP LOCKED, marks them running, commits, does the work, then marks done or schedules a retry. SKIP LOCKED means workers never wait on each other; the docs say it suits "a queue-like table". Crashed workers: a reaper resets jobs stuck in running past a timeout, so handlers must be idempotent. Fine up to moderate rates; beyond that, use a real broker (page 09).
21. What is a deadlock and how do you avoid it? hard
Show answer
Two transactions each hold a lock the other needs: T1 locks account 11111 then wants 22222; T2 locks 22222 then wants 11111. Neither can proceed. PostgreSQL detects the cycle and aborts one, which must retry. Avoid by acquiring locks in a consistent order (e.g. sort account ids before updating), taking the strongest lock you need first instead of upgrading, keeping transactions short, and not waiting on users or network calls while holding locks. Always have retry logic for deadlock and serialization errors.

—Cheat sheet

Facts to memorise memorise

FactValueSource
PostgreSQL default isolationRead CommittedPG transaction-iso
MySQL InnoDB default isolationRepeatable ReadMySQL 8.4
PG Repeatable ReadSnapshot isolation; no phantoms; write skew possiblePG transaction-iso
Serialization failureSQLSTATE 40001; retryPG transaction-iso
Default index typeB-tree (hash = equality only)PG indexes-types
Page sizeUsually 8 kBPG storage-page-layout
Composite index ruleEquality on leading columns + range on next; > 3 columns rarely helpsPG indexes-multicolumn
WAL ruleLog flushed before data pagesPG wal-intro
max_connectionsTypically 100PG runtime-config-connection
PgBouncer memory~2 kB per connectionPgBouncer
DynamoDB indexes20 GSIs (default quota), 5 LSIs per tableDynamoDB quotas
MongoDB document16 MiB maxMongoDB limits
LSM-tree originO'Neil et al., 1996Acta Informatica
BASEPritchett, ACM Queue, 2008ACM Queue

Reference

—Sources

All checked 2026-10-25 sources

WhatWhere
Isolation levels, Table 13.1, write skew examplepostgresql.org transaction-iso
MVCCpostgresql.org mvcc-intro
Transactions (ACID wording)postgresql.org tutorial-transactions
Row locks, deadlockspostgresql.org explicit-locking, sql-select
WALpostgresql.org wal-intro
Index types, multicolumn, index-only, overheadindexes-types, indexes-multicolumn, index-only scans, indexes-intro
EXPLAINpostgresql.org using-explain
LIMIT/OFFSETpostgresql.org queries-limit
Page size, max_connectionsstorage-page-layout, runtime-config-connection
InnoDB isolationdev.mysql.com 8.4
LSM-tree paperO'Neil, Cheng, Gawlick, O'Neil 1996
Cassandra storage and modelingstorage engine, data modeling
RocksDB compactionRocksDB wiki
DynamoDBcore components, quotas
MongoDBdata modeling, limits
Neo4jgraph database concepts
BASEPritchett, ACM Queue 2008 (page blocks automated fetch; author and venue confirmed via search)
Relational modelCodd 1970, CACM
N+1Rails guides
PoolingPgBouncer features
Optimistic lockmartinfowler.com

Course

—Keep going

Previous and next pages course