02 Β· CS Fundamentals
Databases β Postgres from the inside out
You run Postgres in production. Most candidates can write a JOIN; far fewer can explain why the planner picked an index scan, what MVCC row versions are, or which isolation level their app actually runs at. That second group gets hired. This page puts you there.
01The relational model in 60 seconds
Data as relations (tables) of tuples (rows) with typed attributes (columns). Three ideas made it win:
You state what you want (SQL); the planner/optimizer decides how β which index, which join algorithm, which order. You tune by shaping its options (indexes, statistics), not by writing access paths.
Primary keys, foreign keys, NOT NULL, UNIQUE, CHECK. The DB enforces invariants so no app touching the data can corrupt it.
Each fact stored once (no update anomalies); recombine with joins. Denormalize deliberately, later, for read performance β eyes open about the consistency cost.
02SQL joins β with worked examples
Two tables. Note Chen has no orders, and order 13 is a guest order with no user:
users orders
id β name id β user_id β total
ββββΌββββββ ββββΌββββββββββΌββββββ
1 β Asha 10 β 1 β 500
2 β Ben 11 β 1 β 250
3 β Chen 12 β 2 β 90
13 β NULL β 40 (guest order)
-- INNER JOIN: only matching pairs (Chen and the guest order vanish)
SELECT u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id;
-- Asha 500 Β· Asha 250 Β· Ben 90
-- LEFT JOIN: all left rows; NULLs where no match (Chen kept)
SELECT u.name, o.total FROM users u LEFT JOIN orders o ON o.user_id = u.id;
-- Asha 500 Β· Asha 250 Β· Ben 90 Β· Chen NULL
-- Classic idiom β "users with no orders": LEFT JOIN + IS NULL (anti-join)
SELECT u.* FROM users u LEFT JOIN orders o ON o.user_id = u.id
WHERE o.id IS NULL; -- β Chen
Mirrored LEFT: all right rows, NULL-padded left. Rarely written β flip the tables and use LEFT.
Keep unmatched rows from both sides: Chen (no orders) and the guest order (no user) both appear.
Cartesian product β every pair. Usually an accident unless you meant it.
Table joined to itself: employees e JOIN employees m ON e.manager_id = m.id.
The LEFT-JOIN-in-WHERE trap
A filter on the right table in the WHERE clause turns a LEFT JOIN back into an INNER β NULLs fail every comparison. Put right-table filters in the ON clause:
β Silently becomes INNER
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE o.status = 'paid';
-- Chen's row: o.status is NULL,
-- NULL = 'paid' is not true β
-- Chen dropped. LEFT is now INNER.β Filter in ON keeps left rows
SELECT u.name, o.total
FROM users u
LEFT JOIN orders o
ON o.user_id = u.id
AND o.status = 'paid';
-- Chen survives with NULL total:
-- the filter shapes the match,
-- not the result set.How the engine executes joins
The planner picks per statistics β reading EXPLAIN ANALYZE and saying which appeared and why is a standout skill:
Small outer Γ indexed inner. Great for "one user's orders": probe the index per outer row.
Build a hash table on the smaller side, probe with the larger. Wins for big unsorted sets.
Both sides sorted (or indexed), zip them together in one pass.
03Indexes β deeply
Without an index, WHERE email = ? is a sequential scan: read every row. O(N) with hefty constants (disk pages). An index is a redundant, ordered structure that trades write cost + space for read speed.
B-tree structure β the default, in Postgres and nearly everyone
A B-tree (B+ tree in practice) is a short, extremely wide sorted tree of fixed-size pages (8 KB in Postgres). Follow the highlighted path for a lookup of noor:
- Leaves are a sorted linked list β ranges (
BETWEEN,>,ORDER BY x LIMIT k, prefixLIKE 'abc%') walk to the start then scan sideways. This is why B-trees beat hash indexes for anything but pure equality. - Self-balancing: pages split on overflow, height grows from the root β always balanced.
- Leaf entries point to the table row (in Postgres, a heap tuple via its
ctid) β after the index finds candidates, fetching other columns requires a heap fetch per rowβ¦ unless the index is covering (below).
Composite indexes and the leftmost-prefix rule
CREATE INDEX ON orders (user_id, created_at) sorts by user_id, then created_at within each user_id:
(u1,jan) (u1,feb) (u1,mar) (u2,jan) (u2,apr) (u3,feb) ...
βββββββ user 1 βββββββ
- Serves:
WHERE user_id = ?Β·WHERE user_id = ? AND created_at > ?Β·WHERE user_id = ? ORDER BY created_at DESC LIMIT 20β the index provides the order, no sort step. That last one is the timeline-query pattern. - Does not efficiently serve
WHERE created_at > ?alone β you would have to jump into every user's section. The leftmost prefix must be constrained.
Covering indexes (index-only scans)
If every column the query needs is in the index, skip the heap entirely:
CREATE INDEX ON orders (user_id, created_at) INCLUDE (total);
-- SELECT total FROM orders WHERE user_id=? ORDER BY created_at DESC LIMIT 20
-- β Index Only Scan: answers straight from index leaves
When indexes hurt
- Every write pays. INSERT/UPDATE/DELETE must maintain every index on the table β 6 indexes β 7Γ write amplification (plus WAL). Heavy-ingest tables want minimal indexes.
- Low selectivity is useless. An index on
statuswith 3 values returning 30% of the table loses to a seq scan (a random heap I/O per match costs more than streaming the table). The planner knows this from statistics and will ignore the index β correctly. - Unanchored patterns and functions:
LIKE '%foo'cannot use a B-tree (that is what trigram/GIN or OpenSearch is for);WHERE lower(email) = ?needs an expression index onlower(email). - Space + memory: indexes compete with data for cache; bloat needs reindexing.
- Postgres-specific: updating an indexed column defeats the HOT (heap-only-tuple) update optimization β more index churn.
Toolbox beyond B-tree
| Index type | One-liner |
|---|---|
| GIN | Inverted index β arrays, JSONB, full-text; conceptually what OpenSearch does. |
| GiST / SP-GiST | Geometric / nearest-neighbor queries. |
| BRIN | Block ranges β huge append-only time-series, tiny footprint. |
| Hash | Equality only; no ranges, no ordering. |
| Partial | WHERE deleted_at IS NULL β index only the hot subset. |
EXPLAIN (ANALYZE, BUFFERS) β look for Seq Scan on big tables, Sort nodes an index could kill, rows-estimated vs rows-actual drift (stale stats), and Buffers read vs hit.04Transactions and ACID β via the WAL
A transaction is a group of operations that executes as one all-or-nothing unit:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
UPDATE accounts SET balance = balance + 100 WHERE id = 2;
COMMIT; -- both visible atomically, or neither
All or nothing. Crash mid-transfer β both legs roll back. Implemented via the WAL (write-ahead log): changes hit the durable log before data pages; recovery replays committed work and discards uncommitted.
Transactions move the DB between valid states β constraints, FKs, triggers hold. Partly the app's job: the DB gives you the primitives.
Concurrent transactions don't trample each other; how much is the isolation-level dial (next section).
Once COMMIT returns, the data survives power loss β the WAL is fsync'd to disk at commit.
await an external API call is a classic outage. Never hold a transaction across an LLM/API call. Batch writes; retry on serialization/deadlock errors.05Isolation levels and their anomalies
The dial trades correctness for concurrency. The anomalies first:
| Anomaly | What happens |
|---|---|
| Dirty read | Seeing another transaction's uncommitted data (which may vanish on rollback). |
| Non-repeatable read | Read the same row twice in one txn, get different values (someone committed in between). |
| Phantom read | Run the same query twice, new rows appear/disappear (someone inserted/deleted matching rows). |
| Lost update | Two read-modify-writes interleave; one overwrites the other β the DB flavor of a thread race. |
| Write skew | The sneaky one: two txns each read a condition, both write disjoint rows, combined result violates the invariant β two doctors both check "β₯2 on call" and both go off call. Only Serializable catches it. |
Level Γ anomaly grid
| Level | Dirty read | Non-repeatable | Phantom | Write skew |
|---|---|---|---|---|
| Read Uncommitted | possible * | possible | possible | possible |
| Read Committed PG default | prevented | possible | possible | possible |
| Repeatable Read PG = snapshot | prevented | prevented | prevented in PG β | possible |
| Serializable | prevented | prevented | prevented | prevented |
* Postgres has no real Read Uncommitted β it behaves as Read Committed. β The SQL standard permits phantoms at RR; Postgres's snapshot implementation prevents them.
- Read Committed (what your app almost certainly runs): each statement sees a fresh snapshot of committed data. Fine for most CRUD; read-modify-write across statements can still lose updates β fix with atomic
UPDATE ... SET x = x + 1,SELECT ... FOR UPDATE, or optimistic version columns (UPDATE ... WHERE id=? AND version=?, check rowcount). - Repeatable Read: one snapshot for the whole transaction β consistent reports/exports. Concurrent-write conflicts raise serialization errors β retry loop required.
- Serializable: as if transactions ran one at a time. Postgres uses SSI (serializable snapshot isolation β optimistic, tracks read/write dependencies, aborts cycles) rather than pessimistic range locks. Costs throughput + mandatory retry logic; reach for it when invariants span multiple rows (bookings, inventory, on-call).
06Locking vs MVCC β how Postgres actually does it
Two strategies for isolation:
Pessimistic locking (old SQL Server default)
readers lock rows they read;
writers block readers and
vice versa. Safe β but read/
write workloads serialize.MVCC (Postgres, InnoDB, Oracle)
writers make NEW VERSIONS
instead of overwriting; each
snapshot decides per version
"committed before me?" β reads
take no row locks at all.Every row version carries xmin/xmax (creator/deleter transaction ids):
row id=7: [v1 xmin=100 xmax=205]βββΊ[v2 xmin=205 xmax=β
]
β² old txns' snapshots β² new txns see this
still see v1 (consistent read, no lock!)
The costs β and the vocabulary that proves you run Postgres:
- Dead tuples & VACUUM: old versions accumulate (an UPDATE is insert-new + mark-old); autovacuum reclaims them. Update-heavy tables bloat if vacuum can't keep up; long-running transactions pin old snapshots and block cleanup β the "one stuck
idle in transactionconnection bloats the whole DB" incident. - Every UPDATE also touches indexes (unless HOT applies), and transaction-id wraparound requires vacuum too.
- Explicit locks still exist when you want pessimism:
SELECT ... FOR UPDATE(the queue-worker "claim a job" pattern, especially withSKIP LOCKED), advisory locks (app-level distributed mutex), DDL table locks (whyALTER TABLEon a hot table needs care andCREATE INDEX CONCURRENTLYexists).
07The N+1 query problem
The ORM classic: fetch N parents, then lazily fetch children per parent. 101 round trips at ~1 ms each is 100+ ms of pure network chatter β invisible locally (fast loopback, small data), brutal in prod.
β 1 + N queries
users = session.query(User)
.limit(100).all() # 1 query
for u in users:
print(u.orders) # 100 more
# queries!β 2 queries (or 1)
users = session.query(User)
.options(selectinload(User.orders))
.limit(100).all()
# query 1: the users
# query 2: WHERE user_id IN (...)
# joinedload β a single JOINDetection: query logs / APM showing bursts of identical queries with different ids; SQLAlchemy echo=True in dev. Fixes:
- Eager load / JOIN:
selectinload(User.orders)(1 query for users + 1WHERE user_id IN (...)) orjoinedload(single JOIN). 101 β 2. - Write the join/aggregate in SQL and let the DB do it β it is better at it.
- Batch pattern: collect ids, one
INquery, stitch in memory β the same idea as GraphQL DataLoader.
08Connection pooling
Postgres connections are expensive: each is a forked process with its own memory; TCP + TLS + auth handshake to create; even idle ones consume server resources. The practical ceiling is hundreds, not tens of thousands β while your K8s deployment happily scales to dozens of pods.
Pool = keep K warm connections, lease them per query/transaction:
40 uvicorn workers Γ 100s of in-flight requests
β acquire (or wait β the pool is a semaphore)
βΌ
[ app pool: size 10/pod ] βββΊ optionally PgBouncer βββΊ Postgres
(shared cluster-wide pool, max_connections
transaction-mode pooling)
- App-side pools (asyncpg / SQLAlchemy
pool_size+max_overflow) cap per-pod usage; a cluster-wide pooler (PgBouncer, transaction mode) is how many pods share few DB connections. - Sizing is counterintuitive: throughput usually peaks near
(cores Γ 2) + spindle-ishon the DB β tens, not thousands. Oversized pools just create lock contention and context switching at the DB. - Failure modes you can narrate: pool exhaustion under a slow-query pileup (requests queue at
acquire, p99 explodes, timeouts cascade); leaked connections from un-returned sessions; transaction-mode pooling breaking session state (prepared statements,SET, advisory locks).
09NoSQL taxonomy β and when each fits
| Family | Model | Exemplars | Sweet spot |
|---|---|---|---|
| Key-value | opaque value by key | Redis, DynamoDB(-ish) | caches, sessions, counters, rate limits β O(1) by key, no queries across values |
| Document | nested JSON docs, secondary indexes | MongoDB, Couchbase | self-contained aggregates (product + variants), flexible/evolving schema, read-whole-object patterns |
| Wide-column | rows within partitions, sorted clustering keys | Cassandra, HBase, ScyllaDB | massive write throughput, time-series/feeds, known-query-first modeling, multi-DC β at the price of no joins/ad-hoc queries, (tunable) eventual consistency |
| Graph | nodes + edges, traversal queries | Neo4j | many-hop relationship queries (fraud rings, social paths) where SQL would be 6 self-joins |
| Search engine | inverted index over documents | OpenSearch / ES | full-text relevance, fuzzy matching, log analytics, aggregations β near-real-time, not a source of truth |
Polyglot persistence = your actual architecture: Postgres (truth) + Redis (speed) + OpenSearch (search) + Kafka (movement). The cost of every derived store is keeping it in sync β dual writes drift; CDC (Debezium β Kafka) or a transactional outbox are the grown-up answers.
10ORMs β tradeoffs
For
Β· productivity + type safety on CRUD
Β· migrations tooling (Alembic)
Β· parameterization by default
(SQL-injection resistant)
Β· unit-of-work / session batching
Β· portability (least important
in practice)Against
Β· hides the query β N+1s, accidental
cartesian joins, SELECT * of wide
rows, invisible until you read
the emitted SQL
Β· leaky past medium complexity
(window fns, CTEs, ON CONFLICT)
Β· impedance mismatch: objects are
graphs, tables are sets; lazy
loading is the awkward bridge
Β· session/identity-map state
("detached instance") is its
own bug category11Interview questions you should be able to answer
Q1. Explain the difference between INNER, LEFT, and FULL joins.
INNER: only matching pairs. LEFT: every left row, NULL-padded when no match (LEFT + IS NULL = anti-join, "users with no orders"). FULL: unmatched rows from both sides. Gotcha: filtering the right table in WHERE (not ON) silently converts LEFT to INNER because NULLs fail the predicate.
Q2. How does a B-tree index make queries fast?
Sorted, balanced tree of 8 KB pages with fanout in the hundreds β billions of rows in 3β4 levels, so a lookup is a handful of mostly-cached page reads. Leaves are sorted and linked, so ranges, ORDER BY β¦ LIMIT, and prefix LIKE ride the index. Cost: every write maintains every index, plus space and cache pressure.
Q3. Composite index on (a, b) β which queries can use it?
Leftmost-prefix rule: a = ?, a = ? AND b > ?, a = ? ORDER BY b (no sort needed) β yes. b = ? alone β no. Design: equality columns first, then the one range/sort column. Add INCLUDE columns to make it covering β index-only scan, no heap fetches.
Q4. When would you NOT add an index?
Write-heavy tables (each index amplifies every write), low-selectivity columns (planner rightly prefers a seq scan past a few % of rows), patterns B-trees can't serve (%foo, un-indexed expressions), and when table+indexes outgrow cache. Indexes are a read-for-write trade β justify each with a query.
Q5. ACID β and how are atomicity/durability actually implemented?
Atomic (all-or-nothing), Consistent (constraints hold), Isolated (concurrency dial), Durable (survives crash). Mechanism: write-ahead log β changes logged and fsync'd before COMMIT returns; recovery replays committed and discards uncommitted work.
Q6. Isolation levels and their anomalies?
Dirty read (uncommitted data), non-repeatable read (row changes across two reads), phantom (result set changes), lost update, write skew. Read Committed (Postgres default): fresh snapshot per statement β allows non-repeatable/phantom/lost-update across statements. Repeatable Read: one snapshot per txn (PG blocks phantoms too); write conflicts error β retry. Serializable: PG's SSI aborts dangerous patterns, the only level catching write skew. Practical lost-update fixes at RC: atomic UPDATE, FOR UPDATE, or optimistic version columns.
Q7. How does Postgres handle concurrent reads and writes (MVCC)?
Writers create new row versions (xmin/xmax) instead of overwriting; each transaction reads the versions visible to its snapshot. Readers never block writers and vice versa; only same-row writers queue. Costs: dead tuples β autovacuum, bloat under heavy updates, and long-running transactions pinning old snapshots (the idle-in-transaction incident). Explicit pessimism when needed: FOR UPDATE [SKIP LOCKED] for job claiming, advisory locks.
Q8. What is the N+1 problem and how do you fix it?
ORM lazy loading turns "list 100 users with orders" into 1 + 100 queries β death by round trips. Detect via query logs/APM repetition. Fix: eager loading (selectinload/joinedload β 2 or 1 queries), a proper SQL join/aggregate, or batched IN loading (DataLoader pattern).
Q9. Why connection pooling? How do you size a pool?
PG connections are per-process and expensive; setup costs TCP+TLS+auth; server capacity is hundreds. Pools reuse warm connections (semaphore semantics). Size small β throughput peaks around a few Γ DB cores; huge pools add contention. Many pods Γ small pools still overwhelm one DB β PgBouncer in transaction mode (with its session-state caveats). Failure story: slow queries β pool exhaustion β acquire timeouts cascade.
Q10. SQL vs NoSQL β how do you choose?
Default to Postgres: transactions, joins, constraints, JSONB flexibility. Add specialized stores for specific pressures β Redis (KV: cache/session/counters), OpenSearch (inverted-index search/analytics), wide-column (write firehose at multi-node scale, query-first modeling, no joins), graph (deep traversals). Key cost of polyglot: syncing derived stores β prefer CDC/outbox over dual writes.
Q11. Tradeoffs of using an ORM?
Wins: CRUD velocity, migrations, parameterization, session batching. Losses: hidden SQL (N+1, fat selects), leaky abstraction at complex-query altitude, impedance mismatch / lazy-loading pitfalls. My rule: ORM for routine CRUD, handwritten SQL for hot paths and reporting, EXPLAIN ANALYZE on anything suspicious.
Q12. Two requests increment the same counter β what can go wrong and what do you do?
At Read Committed, read-then-write loses updates under interleaving. Make it atomic: UPDATE t SET n = n + 1 WHERE id=? (row lock serializes writers), or Redis INCR for hot counters, or FOR UPDATE when logic must run between read and write, or optimistic versioning with retry. Same race as a threads counter β solved at the datastore instead of with an app mutex.
Self-test β close the page and answer from memory
- Write the "users with no orders" query two ways (anti-join and NOT EXISTS). Which rows vanish if you filter
o.statusin WHERE instead of ON? - Sketch the leaf layout of an index on
(user_id, created_at). Why can't it serveWHERE created_at > ?alone? - Explain why 3β4 page reads suffice to find one row in a billion. What is the fanout, and where do range scans come from?
- What exactly does the WAL guarantee at COMMIT, and what does crash recovery replay vs discard?
- Name the anomaly only Serializable prevents, and give the two-doctors example. What is SSI?
- Walk through xmin/xmax for an UPDATE. Why does one
idle in transactionconnection bloat the whole DB? - Your API p99 exploded and every request is stuck at pool
acquireβ narrate the failure chain and two fixes. - Name the three join algorithms and when the planner picks each.
- Why is dual-writing Postgres + OpenSearch a trap, and what do you do instead?