Reference
DBMS cheat sheet
Everything you should recall without thinking. Print it
(Ctrl/⌘+P — the layout is print-styled).
Keys & normal forms
| Key | In one line |
| Super key | Any uniquely-identifying attribute set, redundant extras allowed |
| Candidate key | A minimal super key |
| Primary key | The candidate key you chose — NOT NULL + UNIQUE |
| Alternate key | The candidate keys you didn't choose |
| Composite key | More than one attribute |
| Foreign key | References another table's PK; may be NULL |
| Surrogate / natural | Artificial id / real-world identifier. Prefer surrogate PK + UNIQUE on the natural key. |
── NORMAL FORMS ─────────────────────────────────────────────────
1NF atomic values, no repeating groups
2NF 1NF + no PARTIAL dependency (on part of a composite key)
3NF 2NF + no TRANSITIVE dependency (non-prime → non-prime)
BCNF every determinant is a superkey (3NF without the prime exception)
4NF BCNF + no multivalued dependency
5NF 4NF + no join dependency
Mnemonic: "the key, the whole key, and nothing but the key"
2NF fixes "the whole key" · 3NF fixes "nothing but the key"
Anomalies normalisation removes:
update — same fact in many rows; miss one and the DB contradicts itself
insertion — can't record a department until it has an employee
deletion — removing the last employee destroys the department
Lossless decomposition: R1 ∩ R2 must be a superkey of R1 or R2.
Tradeoff: BCNF is always lossless but may NOT be dependency-preserving;
3NF can always be both. Hence "3NF is usually enough".
── FINDING CANDIDATE KEYS ───────────────────────────────────────
Compute attribute closure X⁺: start with X, repeatedly apply any FD
whose left side is inside your set, add its right side. If X⁺ = all
attributes and no proper subset does, X is a candidate key.
· an attribute never on any RIGHT side → in EVERY candidate key
· an attribute only on RIGHT sides → in NO candidate key
PRIME attribute = part of some candidate key (this is what 2NF/3NF use)
SQL patterns worth memorising
── TOP N PER GROUP (the most-asked pattern) ─────────────────────
WITH r AS (SELECT *, ROW_NUMBER() OVER (PARTITION BY g ORDER BY x DESC) rn
FROM t)
SELECT * FROM r WHERE rn <= 3;
-- CTE required: you cannot filter a window function in WHERE
── Nth HIGHEST DISTINCT VALUE ───────────────────────────────────
SELECT DISTINCT x FROM (SELECT x, DENSE_RANK() OVER (ORDER BY x DESC) rk
FROM t) s WHERE rk = 2;
-- DENSE_RANK, not ROW_NUMBER — ties should share a rank
── ROWS WITH NO MATCH (anti-join) ───────────────────────────────
SELECT c.* FROM customers c
WHERE NOT EXISTS (SELECT 1 FROM orders o WHERE o.customer_id = c.id);
-- NEVER `NOT IN (subquery)` on a nullable column → returns 0 rows
── DEDUPLICATE, KEEPING THE NEWEST ──────────────────────────────
DELETE FROM t WHERE id IN (
SELECT id FROM (SELECT id, ROW_NUMBER() OVER
(PARTITION BY k ORDER BY created_at DESC, id DESC) rn FROM t) s
WHERE rn > 1);
── RUNNING TOTAL & MOVING AVERAGE ───────────────────────────────
SUM(x) OVER (ORDER BY d)
AVG(x) OVER (ORDER BY d ROWS BETWEEN 6 PRECEDING AND CURRENT ROW)
-- ROWS counts physical rows; RANGE groups peers with equal sort keys
── PERIOD-OVER-PERIOD CHANGE ────────────────────────────────────
x - LAG(x) OVER (ORDER BY month)
100.0 * (x - LAG(x) OVER (...)) / NULLIF(LAG(x) OVER (...), 0)
-- 100.0 forces float; NULLIF guards division by zero
── PIVOT / CONDITIONAL AGGREGATION ──────────────────────────────
COUNT(*) FILTER (WHERE status = 'paid') -- standard
SUM(CASE WHEN status = 'paid' THEN 1 ELSE 0 END) -- portable
── DETAIL + GROUP AGGREGATE ON ONE ROW ──────────────────────────
AVG(salary) OVER (PARTITION BY department_id)
-- impossible with GROUP BY without a self-join
── GAPS IN A TIME SERIES ────────────────────────────────────────
FROM generate_series(d1, d2, '1 day') AS d(day)
LEFT JOIN t ON t.created_at >= d.day AND t.created_at < d.day + 1
-- range predicate, not ::date — a cast on the column kills the index
── KEYSET PAGINATION (never deep OFFSET) ────────────────────────
WHERE (created_at, id) < (?, ?) ORDER BY created_at DESC, id DESC LIMIT 20
── SAFE INCREMENT / TRANSFER ────────────────────────────────────
UPDATE accounts SET balance = balance - 100
WHERE id = ? AND balance >= 100; -- atomic; check rowcount
── OPTIMISTIC CONCURRENCY ───────────────────────────────────────
UPDATE t SET v = ?, version = 8 WHERE id = ? AND version = 7;
-- 0 rows ⇒ someone else won ⇒ reload and retry
── RECURSIVE HIERARCHY ──────────────────────────────────────────
WITH RECURSIVE t AS (
SELECT id, mgr, 1 d FROM e WHERE id = ?
UNION ALL -- ALL, not UNION
SELECT e.id, e.mgr, t.d+1 FROM e JOIN t ON e.mgr = t.id
WHERE t.d < 10) -- depth guard vs cycles
SELECT * FROM t;
Execution order & NULL logic
── EXECUTION ORDER (explains nearly every error message) ────────
1 FROM/JOIN → 2 WHERE → 3 GROUP BY → 4 HAVING
→ 5 SELECT → 6 DISTINCT → 7 ORDER BY → 8 LIMIT
WHERE can't use a SELECT alias (SELECT runs later) — ORDER BY can
WHERE can't use an aggregate (no groups yet) — use HAVING
Non-aggregated SELECT column must be in GROUP BY
Window functions run after WHERE → filter them in an outer query
── THREE-VALUED LOGIC ───────────────────────────────────────────
NULL = NULL → UNKNOWN NULL IS NULL → TRUE
NULL != 5 → UNKNOWN NULL + 5 → NULL
FALSE AND UNKNOWN → FALSE TRUE OR UNKNOWN → TRUE
WHERE keeps a row only when the condition is TRUE.
Five NULL bugs that ship:
· status != 'x' silently EXCLUDES NULL rows → use IS DISTINCT FROM
· NOT IN (subquery) returns nothing if the subquery has any NULL
· COUNT(col) ignores NULLs; COUNT(*) doesn't → the two disagree
· AVG(col) divides by the NON-NULL count → missing data inflates it
· UNIQUE allows MULTIPLE NULLs → no protection against duplicate unknowns
Fixes: COALESCE(x, default) · NULLIF(x, 0) · IS [NOT] DISTINCT FROM
Index rules
| Rule | Why |
Leftmost prefix: (a,b,c) serves a, a+b, a+b+c — never b alone | The index is sorted by a first, so b's values are scattered |
| Equality first, range/sort last | The index then supplies both the lookup and the ordering, so the sort step disappears |
| Covering beats everything | Index-only scan — no table access at all. Use INCLUDE. |
| Selectivity > ~5–10% → sequential scan wins | Sequential reads beat thousands of random fetches |
| Function/cast on the column kills the index | LOWER(email), YEAR(created_at), implicit casts. Index the expression instead. |
Leading % in LIKE kills it | B+ tree order starts from the left |
| Stale statistics is the most common "why isn't it using my index?" | Run ANALYZE |
| Every index is maintained on every write | 10 indexes = 11 write ops per insert, plus 30–100% disk |
| Random UUID PK is bad in InnoDB | Clustered, so random inserts split pages. Use bigint or UUIDv7. |
| Index type | For |
| B+ tree | The default — equality, range, prefix, ORDER BY, MIN/MAX |
| Hash | Equality only. Rarely worth it. |
| Partial / filtered | A hot subset: WHERE status='pending' |
| Expression | ON users (LOWER(email)) |
| GIN / inverted | Full text, JSONB containment, arrays |
| GiST / R-tree | Geometric, geospatial |
| BRIN | Huge naturally-ordered tables (time series) — tiny index |
Join algorithms & EXPLAIN
| Algorithm | Cost | Chosen when | Fails when |
| Nested loop | O(n log m) with an inner index | Small outer side; OLTP point queries | The outer row estimate is badly wrong |
| Hash join | O(n + m) | Large equality joins | Hash table doesn't fit → spills to disk. Can't do inequality. |
| Merge join | O(n + m) if pre-sorted | Inputs already sorted by an index | Needs a sort otherwise |
── READING EXPLAIN ANALYZE — inside-out, bottom-up ──────────────
FIRST: compare estimated rows vs actual rows.
Order-of-magnitude gap ⇒ fix STATISTICS before adding indexes.
Seq Scan + high "Rows Removed by Filter" → missing index
Nested Loop with huge actual outer rows → bad estimate
Sort … "external merge Disk: 42MB" → raise work_mem, or index the order
Hash … "Batches: 8" → hash spilled; raise work_mem
Index Scan then many heap fetches → make the index COVERING
Fast alone, slow under load → lock contention, not the plan
EXPLAIN = plan + estimates only
EXPLAIN ANALYZE = actually RUNS it (careful with DML!)
EXPLAIN (ANALYZE, BUFFERS) = plus cache hit/read counts
Isolation grid
| Level | Dirty read | Non-repeatable | Phantom | Write skew |
| Read uncommitted | ✗ | ✗ | ✗ | ✗ |
| Read committed (PG default) | ✓ | ✗ | ✗ | ✗ |
| Repeatable read (MySQL default) | ✓ | ✓ | ✗* | ✗ |
| Snapshot isolation | ✓ | ✓ | ✓ | ✗ |
| Serializable | ✓ | ✓ | ✓ | ✓ |
✓ = prevented, ✗ = possible. *MySQL's repeatable read does prevent
phantoms via gap locks — stronger than the standard requires.
The four caveats that matter more than the grid
1. Postgres has no read uncommitted — it gives read committed,
because MVCC makes dirty reads impossible.
2. Postgres's "repeatable read" is really snapshot isolation, so it
allows write skew.
3. Postgres's SERIALIZABLE uses SSI: it detects dangerous patterns
and aborts, so you must handle serialisation failures with
retries.
4. MVCC needs VACUUM; a long-running transaction blocks
cleanup and bloats the database. One forgotten open transaction is the classic
incident.
── ANOMALIES ────────────────────────────────────────────────────
dirty read read uncommitted data
non-repeatable read same row, different value within one transaction
phantom read same query, new rows appear
lost update two read-modify-writes, one silently overwrites
write skew both read a valid state, write DIFFERENT rows,
jointly break an invariant ← SI cannot prevent this
── FIXING LOST UPDATE (best first) ──────────────────────────────
1. arithmetic in the DB: SET balance = balance - 100 ← atomic, no locks
2. SELECT ... FOR UPDATE ← explicit row lock
3. optimistic: WHERE version = 7, retry on 0 rows
── 2PL ──────────────────────────────────────────────────────────
growing phase (acquire only) → shrinking phase (release only)
STRICT 2PL holds exclusive locks to commit → strict schedules,
no cascading rollback. The standard.
Deadlock: DB builds a wait-for graph, detects the cycle, ABORTS a victim
(an OS can't — it has no safe rollback). Prevent by locking rows in a
consistent order and keeping transactions short.
── MVCC ─────────────────────────────────────────────────────────
Writers make new VERSIONS; readers take NO locks and see a snapshot.
→ readers never block writers, writers never block readers
→ but write-write contention on the same row remains
→ old versions are garbage: VACUUM (PG) / undo log (MySQL)
── WAL & RECOVERY ───────────────────────────────────────────────
RULE: the log record reaches stable storage BEFORE the data page.
Commit = flush log to the commit record → one fsync (~1 ms)
⇒ commit rate is bounded by DISK, not CPU. Group commit batches them.
Log record holds BEFORE-image (undo) and AFTER-image (redo).
STEAL + NO-FORCE buffer policy ⇒ BOTH redo and undo are needed.
ARIES: analysis → redo (repeat ALL history) → undo losers (write CLRs).
Checkpoint = the RTO dial: frequent → fast recovery, more steady I/O.
Bonus uses of the WAL: point-in-time recovery, replication, CDC.
Numbers
8 KBPostgres page (16 KB InnoDB)
3–4B+ tree levels for millions of rows
~100 nsbuffer pool page hit
~100 µsSSD page read (a miss)
>99%healthy buffer hit rate
~1 msfsync — bounds commit rate
10k–50kwrites/s, one PG primary
~5%selectivity where index stops winning
+30–100%index size vs table
~100skeys per B+ tree node (fan-out)
The distinction table
| Pair | The one difference |
| Candidate / primary key | All minimal unique sets / the one you chose |
| Surrogate / natural key | Artificial / real-world — and natural keys change |
| 3NF / BCNF | 3NF allows a non-superkey determinant if the dependent is prime |
| WHERE / HAVING | Filters rows / filters groups |
COUNT(*) / COUNT(col) | Rows / non-NULL values |
| ROW_NUMBER / RANK / DENSE_RANK | Always distinct / gaps after ties / no gaps |
| EXISTS / IN | Short-circuits, NULL-safe / simple lists, NULL-dangerous |
| View / materialised view | Stores a query / stores the result |
| DELETE / TRUNCATE | DML, WHERE, triggers, per-row log / DDL, all rows, fast |
| Clustered / secondary index | Leaves are the rows / leaves point to them |
| B+ tree / hash index | Ranges and ordering / equality only |
| Nested loop / hash join | Small outer + inner index / large equality joins |
| Optimistic / pessimistic | Validate at commit / lock up front |
| 2PL / MVCC | Readers block / readers never block |
| Redo / undo | Reapply committed / roll back uncommitted |
| ACID "C" / CAP "C" | Integrity constraints / replica agreement |
| Schema / instance | The structure / the data in it now |
| Function / procedure | Returns a value, usable in a query / invoked for effects |
Decision trees
Which index?
What does the hot query do?
├─ equality on one column → single-column B+ tree
├─ equality + range/sort → composite: EQUALITY first, range/sort LAST
├─ equality on a small subset → PARTIAL index on that predicate
├─ compares a transformed value → EXPRESSION index (LOWER(x), (a+b))
├─ full-text or JSONB containment → GIN
├─ geospatial → GiST
└─ range scan on an append-only
time-ordered table → BRIN
Then ask: can it be COVERING? (include the selected columns)
Then ask: does an existing index already cover this prefix?
Which isolation level?
Is being briefly wrong a bug or a shrug?
├─ SHRUG (listings, dashboards) → READ COMMITTED
├─ needs a stable snapshot across
│ several reads (a report) → REPEATABLE READ / snapshot
├─ read-modify-write on one row → don't rely on isolation:
│ do the arithmetic IN the UPDATE,
│ or SELECT ... FOR UPDATE
└─ multi-row INVARIANT (write skew) → SERIALIZABLE
+ retry serialisation failures
"This query is slow"
1. EXPLAIN ANALYZE. Never guess.
2. estimated vs actual rows wildly different? → ANALYZE (statistics)
3. Seq Scan removing most rows by filter? → add an index
4. index exists but unused? → function/cast on column,
low selectivity, or stale stats
5. Sort or Hash spilling to disk? → work_mem, or index the order
6. still slow? → question the QUERY
(SELECT *, DISTINCT hiding a
bad join, deep OFFSET)
7. still slow? → question the SCHEMA
(materialised view, summary
table, or move it to a warehouse)
Schema design order
entities + ACCESS PATTERNS → relationships & cardinality
→ normalise to 3NF → name any deliberate denormalisation
→ keys (surrogate PK + UNIQUE natural key)
→ constraints (NOT NULL, CHECK, FK with explicit ON DELETE)
→ indexes for the hot queries (column order reasoned)
→ time: TIMESTAMPTZ, and what needs HISTORY
(a mutable product price silently re-prices old orders —
capture the price ON THE ORDER LINE)
→ say what you'd defer: partitioning, archival, sharding
The three sentences to leave with
1. Four bargains: normalisation buys write correctness with join
cost, indexes buy read speed with write cost, isolation buys correctness with
throughput, durability buys safety with latency.
2. NULL is "unknown", so any comparison with it is UNKNOWN, and
WHERE only keeps TRUE.
3. Before adding an index, check whether the planner's row estimate
was even right.