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:

Declarative queries

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.

Integrity in the schema

Primary keys, foreign keys, NOT NULL, UNIQUE, CHECK. The DB enforces invariants so no app touching the data can corrupt it.

Normalization

Each fact stored once (no update anomalies); recombine with joins. Denormalize deliberately, later, for read performance β€” eyes open about the consistency cost.

A UNIQUE constraint is your cheapest race-condition killer: two concurrent "create user" requests β€” one wins, one gets a clean error. No app-level locking needed.

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
RIGHT JOIN

Mirrored LEFT: all right rows, NULL-padded left. Rarely written β€” flip the tables and use LEFT.

FULL OUTER

Keep unmatched rows from both sides: Chen (no orders) and the guest order (no user) both appear.

CROSS JOIN

Cartesian product β€” every pair. Usually an accident unless you meant it.

Self-join

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:

Nested loop

Small outer Γ— indexed inner. Great for "one user's orders": probe the index per outer row.

Hash join

Build a hash table on the smaller side, probe with the larger. Wins for big unsorted sets.

Merge join

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:

k < m β”‚ k β‰₯ m root Β· read β‘  k < d β”‚ k β‰₯ d k < p β”‚ k β‰₯ p read β‘‘ routing only ada Β· ben Β· chen dev Β· eli Β· gus maya Β· noor Β· omar pia Β· raj Β· zoe leaf Β· read β‘’ ↔ leaves are a sorted linked list β†’ range scans, ORDER BY … LIMIT, prefix LIKE keys route down Β· sorted keys + row pointers live in the leaves
3–4 page reads finds 1 row in a billion. Hundreds of keys per 8 KB page β†’ fanout in the hundreds β†’ a billion rows in 3–4 levels; every lookup is a handful of mostly-RAM-cached page reads. That is the whole magic: O(logfanout N) with a huge base.
  • Leaves are a sorted linked list β†’ ranges (BETWEEN, >, ORDER BY x LIMIT k, prefix LIKE '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.
Ordering rule of thumb: equality columns first, then the range/sort column. A range column first ruins every column after it.

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
Postgres caveat: index-only scans need the visibility map reasonably fresh (vacuum), since tuple visibility lives in the heap.

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 status with 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 on lower(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 typeOne-liner
GINInverted index β€” arrays, JSONB, full-text; conceptually what OpenSearch does.
GiST / SP-GiSTGeometric / nearest-neighbor queries.
BRINBlock ranges β€” huge append-only time-series, tiny footprint.
HashEquality only; no ranges, no ordering.
PartialWHERE deleted_at IS NULL β€” index only the hot subset.
Your verification loop: 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.

🎯 Index picker β€” would the planner thank you?

Read the schema and query, then pick the best index (or no index at all).

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
A β€” Atomicity

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.

C β€” Consistency

Transactions move the DB between valid states β€” constraints, FKs, triggers hold. Partly the app's job: the DB gives you the primitives.

I β€” Isolation

Concurrent transactions don't trample each other; how much is the isolation-level dial (next section).

D β€” Durability

Once COMMIT returns, the data survives power loss β€” the WAL is fsync'd to disk at commit.

Practical wisdom that reads as experience: keep transactions short β€” they hold locks and pin MVCC cleanup. A transaction sitting idle in transaction while you 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:

AnomalyWhat happens
Dirty readSeeing another transaction's uncommitted data (which may vanish on rollback).
Non-repeatable readRead the same row twice in one txn, get different values (someone committed in between).
Phantom readRun the same query twice, new rows appear/disappear (someone inserted/deleted matching rows).
Lost updateTwo read-modify-writes interleave; one overwrites the other β€” the DB flavor of a thread race.
Write skewThe 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

LevelDirty readNon-repeatablePhantomWrite skew
Read Uncommittedpossible *possiblepossiblepossible
Read Committed PG defaultpreventedpossiblepossiblepossible
Repeatable Read PG = snapshotpreventedpreventedprevented in PG †possible
Serializablepreventedpreventedpreventedprevented

* 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!)
Readers never block writers; writers never block readers. Reads take no row locks at all. Writers only conflict with writers on the same row (the second updater waits for the first to commit/abort).

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 transaction connection 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 with SKIP LOCKED), advisory locks (app-level distributed mutex), DDL table locks (why ALTER TABLE on a hot table needs care and CREATE INDEX CONCURRENTLY exists).

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 JOIN

Detection: query logs / APM showing bursts of identical queries with different ids; SQLAlchemy echo=True in dev. Fixes:

  1. Eager load / JOIN: selectinload(User.orders) (1 query for users + 1 WHERE user_id IN (...)) or joinedload (single JOIN). 101 β†’ 2.
  2. Write the join/aggregate in SQL and let the DB do it β€” it is better at it.
  3. Batch pattern: collect ids, one IN query, 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-ish on 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

FamilyModelExemplarsSweet spot
Key-valueopaque value by keyRedis, DynamoDB(-ish)caches, sessions, counters, rate limits β€” O(1) by key, no queries across values
Documentnested JSON docs, secondary indexesMongoDB, Couchbaseself-contained aggregates (product + variants), flexible/evolving schema, read-whole-object patterns
Wide-columnrows within partitions, sorted clustering keysCassandra, HBase, ScyllaDBmassive write throughput, time-series/feeds, known-query-first modeling, multi-DC β€” at the price of no joins/ad-hoc queries, (tunable) eventual consistency
Graphnodes + edges, traversal queriesNeo4jmany-hop relationship queries (fraud rings, social paths) where SQL would be 6 self-joins
Search engineinverted index over documentsOpenSearch / ESfull-text relevance, fuzzy matching, log analytics, aggregations β€” near-real-time, not a source of truth
Honest framing (interviewers reward this): the modern default is Postgres until proven otherwise β€” it does JSONB (document-ish), full-text (small scale), pub/sub (LISTEN/NOTIFY), and honest transactions. Reach for NoSQL for a specific pressure: Redis for sub-ms shared state, OpenSearch for search/relevance, Cassandra-style for write firehoses beyond one node, graph DBs for traversal-shaped data.

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 category
The senior take: ORM for the 80% CRUD, raw SQL (or a query builder) for the hot/complex 20%, and always be able to read EXPLAIN on what the ORM emitted. The ORM is a productivity tool, not an excuse to not know SQL. (Async SQLAlchemy bonus: implicit lazy loads outside a session just raise.)

11Interview 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.status in WHERE instead of ON?
  • Sketch the leaf layout of an index on (user_id, created_at). Why can't it serve WHERE 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 transaction connection 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?