PostgreSQL

PostgreSQL is the open source relational database, descended from Michael Stonebraker's POSTGRES project at Berkeley and developed since 1996 by the PostgreSQL Global Development Group, a community that takes patches by mailing list rather than pull request; the canonical repository lives at git.postgresql.org with a read-only mirror on GitHub. It has quietly become the default backing store for new applications, and its source tree is one of the best-commented large C codebases in existence. This page is a full chapter: a practical tutorial, then the complete life of one UPDATE from the client library through parser, rewriter, planner, executor, heap, and write-ahead log to a durable commit, deep dives into MVCC and vacuum, the WAL, the cost-based planner, and the process model, a staged guide to reading the source, and hands-on labs.

Part I: The mental model

psql / your app
   │  libpq wire protocol (TCP or unix socket)
   ▼
postmaster ── fork() ──▶ one backend process per connection
                              │  tcop/postgres.c  (the traffic cop)
                              ▼
                         parser      (gram.y, analyze.c)
                              ▼
                         rewriter    (views, rules)
                              ▼
                         planner     (paths + costs → plan tree)
                              ▼
                         executor    (pull tuples through plan nodes)
                              ▼
                         heap + indexes   (row versions, never in place)
                              ▼
                shared buffers (cache) ──▶ WAL (durability first)
                              ▼
                         data files on disk

The one-sentence identity: PostgreSQL is a process-per-connection server that compiles each query through a parse, rewrite, plan, execute pipeline, stores rows as immutable versions that vacuum later reclaims, and makes everything durable by logging it to the WAL before it ever touches a data file.

Three commitments define the system. First, the pipeline: every statement, from SELECT 1 to a twelve-way join, passes through the same four stages, and the source tree's directories map onto them almost one to one. Second, MVCC by versioning: an UPDATE never overwrites a row; it writes a new version and marks the old one expired, so readers and writers never block each other, and a background vacuum process pays the bill. Third, WAL first: no change reaches a table or index page on disk before a log record describing it is safely down, and that single log turns out to also be the replication stream and the point-in-time recovery mechanism.

Hold those three and everything operational about Postgres, connection pooling, bloat, autovacuum tuning, replicas, becomes a corollary rather than folklore. For the requirements-first view of when to reach for a server database at all, see my databases note; for the storage-engine spectrum this sits on, the key-value store design write-up and the RocksDB page are the B-tree-versus-LSM companion reads.

Part II: Using it

Install and first session

The quickest zero-install route is the official container (PostgreSQL 18 is current as of this writing; 14 through 17 remain supported):

docker run -d -e POSTGRES_PASSWORD=secret -p 5432:5432 postgres:18
docker exec -it $(docker ps -lq) psql -U postgres

On a real machine the package manager sets up a cluster and a postgres system user (Debian and Ubuntu ship an older major by default; the PGDG apt repository carries current releases):

sudo apt install postgresql        # or: brew install postgresql@18
sudo -u postgres createdb demo
sudo -u postgres psql demo
demo=# CREATE TABLE accounts(id bigint PRIMARY KEY, balance bigint NOT NULL);
demo=# INSERT INTO accounts SELECT g, 1000 FROM generate_series(1, 100000) g;
demo=# UPDATE accounts SET balance = balance - 100 WHERE id = 42;
UPDATE 1
demo=# EXPLAIN ANALYZE SELECT * FROM accounts WHERE id = 42;

That last command is the one to make a habit: EXPLAIN ANALYZE runs the query and prints the plan with estimated and actual costs side by side, and reading it is the daily interface to everything in Parts IV and V.

From code

The natural client library for this chapter is psycopg, the standard PostgreSQL adapter for Python, which speaks the libpq protocol:

pip install "psycopg[binary]"
import psycopg

with psycopg.connect("host=localhost dbname=demo user=postgres password=secret") as conn:
    with conn.cursor() as cur:
        cur.execute(
            "UPDATE accounts SET balance = balance - %s WHERE id = %s",
            (100, 42),
        )
        cur.execute("SELECT balance FROM accounts WHERE id = %s", (42,))
        print(cur.fetchone())
    # the with-block commits on clean exit, rolls back on exception

The mistakes everyone makes first

Mistake one: leaving transactions open. Client libraries commonly start a transaction implicitly on the first statement. A session that runs a query and then sits idle holds its snapshot open, shows up as idle in transaction in pg_stat_activity, blocks vacuum from reclaiming anything newer than its snapshot, and can hold locks. Commit or roll back promptly; set idle_in_transaction_session_timeout as a backstop.

Mistake two: assuming count(*) is cheap. Because visibility is per row version, Postgres generally has to scan to count; there is no maintained row counter. For monitoring, pg_stat_user_tables or an estimate from pg_class.reltuples is usually what you actually want.

Mistake three: string interpolation instead of parameters.

# wrong: injection plus no plan reuse
cur.execute(f"SELECT * FROM accounts WHERE id = {user_input}")
# right
cur.execute("SELECT * FROM accounts WHERE id = %s", (user_input,))

Mistake four: opening a connection per request. Every connection is a forked server process with real setup cost and memory. Web applications need a pool (client-side or PgBouncer); Part III's warning is this mistake at architecture scale.

Mistake five: adding an index and assuming it will be used. The planner is cost-based: on a hundred-row table a sequential scan is genuinely cheaper than an index scan, and on a filter matching half the table it usually is too. The habit that fixes the confusion is checking EXPLAIN ANALYZE before and after, which Lab 2 makes concrete.

Part III: When it is the right tool

PostgreSQL is the right default for a networked application's system of record: multiple services or machines sharing data, sustained concurrent writes, strong constraints and transactions, and workloads that benefit from its unusually deep feature set (rich types, partial and expression indexes, window functions, logical replication, and an extension ecosystem from PostGIS to vector search). Its habit of absorbing adjacent workloads, queue, search, analytics, document store, is often the correct boring choice at small and medium scale.

The named alternatives, and when they win: SQLite when the database and the application share one machine and one writer at a time is acceptable; an embedded file beats a server on operational simplicity every time it is sufficient. MySQL when your organization already runs it well; its clustered primary-key storage (InnoDB) and mature replication tooling remain real strengths, though the technical gap has narrowed to taste in most new-project decisions. DuckDB or a warehouse when the workload is analytical scans over columns rather than transactional point reads and writes; row-store Postgres can be made to do OLAP, but columnar engines are built for it.

The architecture-shaped warning: do not point an elastic fleet of clients directly at Postgres. One connection is one forked process holding real memory, and a serverless platform or large microservice fleet that opens connections freely will exhaust max_connections or thrash the server with process overhead long before the hardware is busy. The safe shape puts a pooler in front, so thousands of client connections multiplex onto tens of backend processes.

SAFE                                     DANGEROUS
1000s of clients                         1000s of lambdas/pods
      │                                        │ │ │ │ │
      ▼                                        ▼ ▼ ▼ ▼ ▼
  PgBouncer (pools)                        postgres: one forked
      │  ~20-50 server conns               process PER connection
      ▼                                    → max_connections hit,
  postgres backends                          memory pressure, collapse

Part IV: The full life of one UPDATE

The canonical operation for this chapter is a write, because a write exercises every mechanism a read does plus versioning, locking, and the WAL:

UPDATE accounts SET balance = balance - 100 WHERE id = 42;

Every file named below was verified to exist in the current master branch of the repository; the behavior described is stable across recent releases and was checked against the PostgreSQL 18 documentation.

Stage 1: libpq and the wire protocol

psql and psycopg both sit on libpq (src/interfaces/libpq/, with statement dispatch in fe-exec.c). A plain PQexec sends the SQL as a single simple-protocol message; parameterized calls use the extended protocol's Parse, Bind, and Execute messages, which is what keeps your parameters out of the SQL text entirely, separating code from data at the protocol level. Either way, bytes arrive at a server socket.

Stage 2: the postmaster and the fork

The listening process is the postmaster (src/backend/postmaster/postmaster.c). At connection time, not query time, it forked a dedicated backend process for this session after authentication; by the time our UPDATE arrives, a private process is already waiting on the socket. Postgres has no threads for query execution: one connection is one process, cooperating with its siblings only through shared memory (buffer cache, lock tables) and signals. Background siblings, the checkpointer, WAL writer, autovacuum launcher and workers, and WAL senders for replicas, were forked by the postmaster at startup.

Stage 3: the traffic cop, tcop/postgres.c

The backend's main loop lives in src/backend/tcop/postgres.c, and for a simple-protocol message the spine of the whole system is one function: exec_simple_query(). It is short enough to read in a sitting and calls the next four stages in order, which makes it the single best entry point into the codebase.

Stage 4: parser and analyzer

The grammar in src/backend/parser/gram.y (bison, one of the largest grammars in open source) produces a raw parse tree; semantic analysis in src/backend/parser/analyze.c then looks up accounts in the catalogs, resolves id and balance to typed references, and resolves the operators, producing a Query node. The split matters: raw parsing needs no database access at all, analysis is all catalog lookups.

Stage 5: the rewriter

src/backend/rewrite/rewriteHandler.c applies the rule system: views are expanded into their defining queries here, and row-level security policies inject their predicates here. Our UPDATE targets a plain table, so it passes through unchanged, but the stage is why a view behaves exactly like its definition; it literally becomes it before planning.

Stage 6: the planner

src/backend/optimizer/plan/planner.c drives planning. Postgres separates paths, cheap descriptions of possible strategies built in src/backend/optimizer/path/allpaths.c, from the final plan tree built only for the winner. Each path is priced by the cost model in src/backend/optimizer/path/costsize.c using statistics that ANALYZE gathered into pg_statistic. For id = 42 on a primary key, the index path wins trivially, and the plan is an index scan feeding a ModifyTable node. EXPLAIN would show:

Update on accounts
  ->  Index Scan using accounts_pkey on accounts
        Index Cond: (id = 42)

Stage 7: the executor

src/backend/executor/execMain.c runs the plan tree as a pull pipeline: each node's function asks its child for the next tuple (the classic Volcano model, one file per node type in executor/, names matching EXPLAIN output almost exactly). The index scan node descends accounts_pkey, finds the heap location for id = 42, checks that the row version is visible to our snapshot, and hands it to src/backend/executor/nodeModifyTable.c, which computes the new tuple with balance - 100 and asks the table's access method to perform the update.

Stage 8: the heap update, a new row version

Here is the load-bearing storage moment, in src/backend/access/heap/heapam.c (heap_update()). Postgres does not modify the row. It writes a complete new tuple whose xmin is our transaction ID, sets xmax on the old tuple to the same ID, and links old to new through the ctid chain. Both versions now coexist in the table's 8 kB pages, in shared buffers (src/backend/storage/buffer/bufmgr.c). If no indexed column changed and the old tuple's page has room, this is a HOT update (heap-only tuple, see src/backend/access/heap/README.HOT) and no index entry is written; otherwise every index on the table gets a new entry pointing at the new version. Our UPDATE changes only balance, which is not indexed, so HOT applies.

Stage 9: WAL record emission

Before the modified buffer page can ever be written to disk, the change is described in a WAL record assembled by src/backend/access/transam/xloginsert.c and copied into WAL buffers by the machinery in src/backend/access/transam/xlog.c. Each record gets a log sequence number (LSN), a byte position in the log, and the page is stamped with it; the buffer manager enforces the write-ahead rule by refusing to evict a data page until the WAL up to that page's LSN is flushed. If this is the first touch of the page since the last checkpoint, the record carries a full page image to defend against torn writes.

Stage 10: commit

Our implicit transaction commits in src/backend/access/transam/xact.c: a commit record is appended to the WAL, and XLogFlush forces the log to durable storage up through it (with synchronous_commit = on, the default, the client does not get success before this fsync). The transaction is then marked committed in pg_xact (src/backend/access/transam/clog.c), locks are released, and the backend sends UPDATE 1 and ReadyForQuery back down the wire. Note what did not happen: the 8 kB data page with our two row versions is still only in shared buffers, dirty. The checkpointer or background writer will get it to disk eventually; if power fails first, crash recovery replays the WAL and reconstructs it. Durability lives in the log, not the data files.

Part V: Internals deep dives

Deep dive 1: MVCC, vacuum, and wraparound

Every heap tuple header carries xmin (the transaction that created it) and xmax (the transaction that deleted or superseded it, 0 if none). A snapshot is essentially "every transaction ID below this horizon, minus this list of still-in-progress ones", taken per statement in READ COMMITTED and per transaction in REPEATABLE READ (snapshot code in src/backend/utils/time/snapmgr.c). A tuple is visible if its xmin committed before the snapshot and its xmax did not. That is the whole trick: readers never block writers because old versions remain readable, and writers never block readers because new versions are invisible until commit.

UPDATE accounts SET balance = 900 WHERE id = 42;   (txn 748)

page: [ (0,1) xmin=701 xmax=748  id=42 balance=1000 ]  old version
      [ (0,2) xmin=748 xmax=0    id=42 balance=900  ]  new version
                 │
   txn 730 (snapshot before 748): sees (0,1)
   txn 749 (after 748 commits):   sees (0,2)

The bill: superseded versions, dead tuples, stay in the file as bloat until VACUUM (src/backend/access/heap/vacuumlazy.c, entry point in src/backend/commands/vacuum.c) removes them, which it may only do once no snapshot can still see them; this is precisely why long-lived transactions cause bloat. Autovacuum triggers per table once dead tuples exceed autovacuum_vacuum_threshold (default 50) plus autovacuum_vacuum_scale_factor (default 0.2, twenty percent) of the table. And the correction worth engraving: plain VACUUM makes dead space reusable but almost never shrinks the file; only VACUUM FULL, which rewrites the table under an exclusive lock, returns space to the operating system.

Vacuum's second, non-negotiable duty is wraparound defense. Transaction IDs are 32-bit and compared circularly, so a given ID can only "see backward" about two billion transactions; a tuple whose xmin falls off that horizon would abruptly look like it came from the future. Vacuum therefore freezes old tuples, marking them visible to everyone, and autovacuum_freeze_max_age (default 200 million) forces an anti-wraparound vacuum on any table that lets its oldest unfrozen ID get that stale. Neglect this on an append-only or vacuum-starved table and Postgres will eventually refuse writes to protect itself; monitoring age(relfrozenxid) is the standard defense.

Deep dive 2: the WAL, one log wearing three hats

The WAL is a stream of records in 16 MB segment files under pg_wal/, addressed by LSN. Its first hat is crash recovery: since every page change is logged before the page can be evicted, replaying from the last checkpoint reconstructs all committed work, and checkpoints (default every five minutes, tunable with checkpoint_timeout and max_wal_size) bound the replay window by forcing dirty buffers to disk. The second hat is replication: a standby is just a server in permanent recovery, replaying WAL shipped to it by a WAL sender process (src/backend/replication/walsender.c), and synchronous replication merely means waiting for the standby's acknowledgment before reporting commit. The third hat is point-in-time recovery: a base backup plus archived WAL replayed to a chosen LSN or timestamp. Crash recovery, replicas, and PITR are not three subsystems; they are one mechanism, the log, consumed three ways. Logical decoding reads the same stream and emits row-level changes, which is how change-data-capture tools ride the WAL. The trap to correct: replication is not backup. A replica faithfully replays your accidental DROP TABLE within milliseconds; only base backups plus WAL archives let you rewind.

Deep dive 3: the planner and reading EXPLAIN ANALYZE

The planner's currency is cost, an abstract unit anchored by seq_page_cost = 1.0. The defaults in costsize.c that matter: random_page_cost = 4.0 (deliberately far below the true random-versus-sequential ratio of magnetic disks because most random reads are assumed cached; on SSDs many operators lower it toward 1.1), cpu_tuple_cost = 0.01, and cpu_index_tuple_cost = 0.005. Selectivity estimates come from ANALYZE's statistics, histograms and most-common-value lists at default_statistics_target = 100 resolution (estimation functions in src/backend/utils/adt/selfuncs.c).

The reading skill, on real output shape:

Index Scan using accounts_pkey on accounts
    (cost=0.29..8.31 rows=1 width=16)
    (actual time=0.041..0.043 rows=1 loops=1)
  Index Cond: (id = 42)
Planning Time: 0.140 ms
Execution Time: 0.070 ms

Read it in this order. First, rows estimated versus rows actual: a large mismatch means stale or insufficient statistics and is the root cause of most bad plans; run ANALYZE, then look again. Second, where the time actually went, remembering that actual time is per loop and multiplies by loops (the classic misread on nested loops). Third, the node types against your expectation: Seq Scan where you expected an index is not a bug until the row estimate says it should have been selective. Costs are unitless planning currency, not milliseconds; only actual time is time. Add EXPLAIN (ANALYZE, BUFFERS) to see shared buffer hits versus disk reads per node.

Deep dive 4: the process model and shared buffers

Postgres runs queries in processes, not threads, a Berkeley-era decision retained for the isolation it buys: a crashing backend takes out one connection while the postmaster resets shared state, and the codebase avoids whole classes of threading bugs. The prices are connection weight (hence pooling) and the constraint that everything shared must live in explicit shared memory. The centerpiece of that shared memory is the buffer cache in src/backend/storage/buffer/bufmgr.c: all table and index pages are read and modified through it, dirty pages are written back lazily by the background writer and at checkpoints, and eviction runs a clock-sweep approximation of LRU. Its size, shared_buffers, defaults to a deliberately tiny 128 MB; the standard starting point on a dedicated machine is around a quarter of RAM, not more, because Postgres reads files through the OS page cache too and the two caches double-buffer. This layered-cache effect is why "cold" query timings are so hard to reason about casually, and why BUFFERS output distinguishes hits from reads: a "read" may still be served from the OS cache without touching the disk.

Part VI: Reading the repository

Clone the mirror and live in src/backend/. The tree's README files are genuinely excellent and every path below was verified against current master.

Stage 0, orientation without code. Read src/backend/optimizer/README (the standout), src/backend/access/transam/README (transactions and WAL), and src/backend/storage/buffer/README. Questions you should be able to answer: what are the four pipeline stages and in which directories do they live? What is the difference between a path and a plan? What rule makes a log "write-ahead"?

Stage 1, the spine. Read src/backend/postmaster/postmaster.c (skim the fork logic), then src/backend/tcop/postgres.c, focusing on exec_simple_query(). Questions: at what moment does a connection get its process? Which functions correspond to parse, rewrite, plan, execute? Where would a second query in the same session re-enter?

Stage 2, front half of the pipeline. Read src/backend/parser/README, skim gram.y for one statement type, read parser/analyze.c, then rewrite/rewriteHandler.c. Questions: why is parsing split from analysis? How does a view become its definition? Where would row-level security predicates attach?

Stage 3, planner. Read optimizer/plan/planner.c top-down, then optimizer/path/allpaths.c and optimizer/path/costsize.c. Questions: where do sequential and index paths get created for a base table? Which function turns the cheapest path into a plan? Which GUCs appear as variables in costsize.c?

Stage 4, executor. Read src/backend/executor/README, then execMain.c, then one scan node and nodeModifyTable.c. Questions: how does the Volcano pull model appear in code? Where does an UPDATE actually call into the heap? How do EXPLAIN node names map to files?

Stage 5, storage and transactions. Read access/heap/heapam.c (find heap_update()), access/heap/README.HOT, storage/buffer/bufmgr.c, access/transam/xact.c, and access/transam/xlog.c last, it is the deep end. Questions: what exactly happens to xmin, xmax, and ctid on update? When is a HOT update possible? What sequence of events constitutes commit?

Where not to start: gram.y in full (tens of thousands of grammar lines with no architecture in them), xlog.c cold (it is among the hardest files in the tree; earn it via the transam README), and the src/include/ headers as a reading path, useful as reference, aimless as a tour.

Part VII: Hands-on labs

All labs run in stock psql against the demo database from Part II. Exact IDs, timings, and sizes will differ on your machine; the shapes will not.

Lab 1: watch row versions with xmin and xmax

-- session A
BEGIN;
UPDATE accounts SET balance = 900 WHERE id = 42;

-- session B (a second psql)
SELECT xmin, xmax, ctid, * FROM accounts WHERE id = 42;
--  xmin | xmax | ctid  | id | balance
--   748 |  751 | (0,42)| 42 |    1000     <- old version, xmax set by A

-- session A
COMMIT;

-- session B again
SELECT xmin, xmax, ctid, * FROM accounts WHERE id = 42;
--   751 |    0 | (0,442)| 42 |     900    <- new version, new ctid

Observe: while A is uncommitted, B still sees the old version but with a nonzero xmax, A's transaction ID staked on it. After commit, B sees the new version with xmin equal to that same ID and a different ctid: the row moved, because it was never updated in place. This is Deep dive 1 with your own eyes.

Lab 2: EXPLAIN ANALYZE before and after an index

CREATE TABLE events(id bigserial, kind text);
INSERT INTO events(kind)
  SELECT (ARRAY['signup','login','click'])[1 + g % 3]
  FROM generate_series(1, 500000) g;

EXPLAIN ANALYZE SELECT count(*) FROM events WHERE kind = 'signup';
-- Seq Scan on events ... rows≈166000 ... Execution Time: tens of ms

CREATE INDEX ON events(kind);
ANALYZE events;
EXPLAIN ANALYZE SELECT count(*) FROM events WHERE kind = 'signup';

Observe: with a third of the table matching, the planner may justifiably stick with a scan or choose an index-only scan; change the predicate to a rare value (insert one 'rare' row and query it) and watch the plan flip decisively to the index with a row estimate near 1. The lesson is Deep dive 3's: plans follow selectivity estimates, not the existence of indexes.

Lab 3: create bloat, then watch vacuum work

CREATE TABLE bloat_demo AS SELECT g AS id, 0 AS n FROM generate_series(1,100000) g;
SELECT pg_size_pretty(pg_relation_size('bloat_demo'));   -- e.g. 3544 kB
UPDATE bloat_demo SET n = 1;                             -- 100k dead tuples
SELECT pg_size_pretty(pg_relation_size('bloat_demo'));   -- roughly doubled
SELECT n_dead_tup, n_live_tup FROM pg_stat_user_tables
 WHERE relname = 'bloat_demo';
VACUUM VERBOSE bloat_demo;
SELECT pg_size_pretty(pg_relation_size('bloat_demo'));   -- barely shrinks!
VACUUM FULL bloat_demo;
SELECT pg_size_pretty(pg_relation_size('bloat_demo'));   -- back to original

Observe three facts from Deep dive 1: the update doubled the heap because every row got a second version; plain VACUUM reported removing dead tuples but the file stayed big (space is reusable, not returned); VACUUM FULL rewrote the table and gave the space back, at the price of an exclusive lock you would not take casually in production.

Lab 4: watch the WAL advance

SELECT pg_current_wal_lsn();                       -- e.g. 0/1A2B3C40
INSERT INTO accounts VALUES (999999, 0);
SELECT pg_current_wal_lsn();                       -- a bit further
SELECT pg_wal_lsn_diff(pg_current_wal_lsn(), '0/1A2B3C40');  -- bytes logged

Observe: every write moves the LSN, and the diff function tells you exactly how many bytes of WAL your operation generated. Try the same insert immediately after a CHECKPOINT; command and see the count jump, because the first touch of a page after a checkpoint logs a full page image (Deep dive 2's torn-write defense, measurable from SQL).

Lab 5: read the system's pulse in pg_stat views

SELECT pid, state, wait_event_type, wait_event, query
  FROM pg_stat_activity WHERE state <> 'idle';

SELECT relname, seq_scan, idx_scan, n_dead_tup, last_autovacuum
  FROM pg_stat_user_tables ORDER BY seq_scan DESC LIMIT 5;

SELECT * FROM pg_stat_database WHERE datname = current_database();

Observe: pg_stat_activity shows every backend process (Deep dive 4 as a table: each row is literally a forked process; look for idle in transaction), pg_stat_user_tables shows which tables are being sequentially scanned (missing indexes announce themselves in seq_scan) and how autovacuum is keeping up with n_dead_tup, and pg_stat_database's blks_hit versus blks_read is your shared-buffers hit rate.

Part VIII: Questions and model answers

Understanding checks; try each before reading the answer.

1. What happens, stage by stage, between typing an UPDATE and seeing UPDATE 1? libpq sends the text to a backend process the postmaster forked at connect time; exec_simple_query runs it through parser and analyzer (parse tree to Query), the rewriter (views, rules), the planner (paths costed, cheapest becomes a plan), and the executor, which finds the row, writes a new version, logs the change to WAL, and at commit fsyncs the WAL before reporting success.

2. What do xmin and xmax mean on a tuple? The transaction IDs that created and (if any) deleted or superseded that version. Visibility is computed per snapshot from them: a version is visible if its creator committed before your snapshot and its deleter, if any, did not.

3. Why do readers never block writers in Postgres? Because an update creates a new version instead of overwriting; readers keep using the old version their snapshot can see while the writer works on the new one. The two only meet when two writers target the same row, which does lock.

4. Why does vacuum exist, in two sentences? Versioning leaves superseded tuples in the heap, and something must reclaim them once no snapshot can see them, or tables bloat without bound. Vacuum also freezes old tuples so the 32-bit transaction counter can wrap without making old data vanish.

5. What is transaction ID wraparound and what prevents it? XIDs are 32-bit and circular, so comparisons only work within a two-billion-transaction horizon; an unfrozen tuple older than that would flip from "past" to "future" and disappear from view. Anti-wraparound vacuums, forced by autovacuum_freeze_max_age (default 200 million), freeze old tuples first, and Postgres will stop accepting writes rather than let the horizon be crossed.

6. What is a HOT update and why does it matter? When an update changes no indexed column and the new version fits on the same page, Postgres links it as a heap-only tuple and writes no new index entries. On hot tables with wide indexing this is the difference between an update costing one page write and costing one per index.

7. Why is "the WAL" the answer to three different questions? Crash recovery replays it, a standby is a server permanently replaying a streamed copy of it, and PITR is a base backup plus archived WAL replayed to a chosen point. One append-only log of page changes happens to be exactly the interface all three need.

8. Why is replication not a backup strategy? Because replicas replay everything, including your mistakes, in near real time; a dropped table is dropped on the standby moments later. Only base backups plus WAL archives (or delayed replicas as a partial mitigation) let you return to a moment before the error.

9. EXPLAIN says cost=0.29..8.31. Milliseconds? No: cost is a unitless currency anchored at seq_page_cost = 1.0, used only to rank candidate plans. Time appears only in EXPLAIN ANALYZE's actual time, and even that is per loop and must be multiplied by loops.

10. The planner ignores your new index. Name the three likeliest reasons. The predicate matches too large a fraction of the table for an index scan to beat a sequential scan; the statistics are stale so the row estimate is wrong (run ANALYZE and compare estimated versus actual rows); or the expression in the query does not match the indexed expression (functions, casts, collations).

11. A table's file barely shrinks after VACUUM. Is that a bug? No. Plain VACUUM marks dead tuple space reusable inside the file and only truncates trailing empty pages; it deliberately avoids the exclusive lock a rewrite needs. VACUUM FULL (or a tool like pg_repack) rewrites the table and returns space to the OS.

12. What does "idle in transaction" cost you? The session holds its snapshot and possibly locks, so vacuum cannot reclaim any tuple version its snapshot might still see, bloat accumulates cluster-wide, and lock waits can pile up behind it. It is the classic silent Postgres incident, and pg_stat_activity is where you catch it.

13. Why does Postgres want a connection pooler when MySQL historically shrugged? A Postgres connection is a forked OS process with meaningful memory and setup cost, while MySQL used a thread per connection. Process-per-connection buys fault isolation but makes connection churn and high connection counts expensive, so poolers multiplex many clients onto few backends.

14. When is SQLite the better choice than Postgres? When the application and data share one machine, writers are few, and operational simplicity dominates: an in-process library with a single-file database eliminates the server, the network, and the credential surface entirely. The crossover is the moment a second machine needs the data.

15. Why can't Postgres answer count(*) from a counter? Because the count depends on the observer: each snapshot sees a different subset of row versions, so no single stored number is correct for everyone. The scan is the price of MVCC; estimates live in pg_class.reltuples when approximate answers suffice.

16. How would you debug a query that is fast in staging and slow in production? Compare EXPLAIN (ANALYZE, BUFFERS) from both: look first for estimated-versus-actual row divergence (statistics differ with data), then buffers hit versus read (cache state differs), then plan shape changes driven by table size crossing a cost threshold. Fixes flow from which of the three diverged: ANALYZE or higher statistics targets, memory or prewarming, or query and index changes.

Part IX: Design lessons

Lesson 1: one durable log can power everything. Postgres derives crash recovery, replication, and time travel from a single WAL rather than three mechanisms. The same consolidation is Kafka's entire identity, RocksDB's WAL plus replication-by-log-shipping, and every event-sourced architecture: when in doubt, make the log the source of truth and everything else a replay.

Lesson 2: decide with an explicit cost model, not heuristics. The planner reduces "which strategy" to arithmetic over a handful of published constants and measured statistics, which makes its decisions inspectable (EXPLAIN) and tunable (random_page_cost) instead of magical. Query optimizers everywhere work this way, but so do good schedulers and load balancers: expose the model, and operators can reason about the system instead of appeasing it.

Lesson 3: buy isolation with processes when correctness is the product. Process-per-connection looks archaic next to thread pools and async runtimes, but it converts memory corruption in one session into a bounded incident instead of a silent cross-session data hazard. Chrome made the same trade with process-per-site, and the pattern generalizes: the more catastrophic shared-state corruption would be, the more an address-space boundary is worth its overhead.

Lesson 4: never pay costs at write time that you can defer to a janitor, but then take the janitor seriously. MVCC moves conflict cost off the hot path and onto vacuum, a deliberate asynchronous debt with a dedicated collector, tunable and observable. Garbage-collected runtimes, LSM compaction in RocksDB, and log-structured filesystems all make the same wager, and all teach the same operational corollary: the deferred work needs first-class monitoring, because it is load-bearing.

Lesson 5: store the system's own definition as data. Types, functions, operators, and index methods are rows in catalogs, which is why CREATE EXTENSION can teach a thirty-year-old server geospatial indexing or vector search without patching it. Plugin registries, Kubernetes CRDs, and LLVM's target registration are the same idea: extensibility is a data-modeling problem before it is an API problem.

Part X: Memorization framework

The one-sentence summary: PostgreSQL forks a process per connection, runs every statement through parse, rewrite, plan, and execute, writes new row versions instead of overwriting (vacuum reclaims, freeze defends the XID horizon), and secures every change in the WAL before data pages ever reach disk, with that same WAL doubling as the replication and recovery stream.

libpq → postmaster → parse → rewrite → plan → execute → heap → WAL → commit

The chain mapped to source files (all under src/):

libpq       interfaces/libpq/fe-exec.c
postmaster  backend/postmaster/postmaster.c
traffic cop backend/tcop/postgres.c        (exec_simple_query)
parse       backend/parser/gram.y, analyze.c
rewrite     backend/rewrite/rewriteHandler.c
plan        backend/optimizer/plan/planner.c,
            path/allpaths.c, path/costsize.c
execute     backend/executor/execMain.c, nodeModifyTable.c
heap        backend/access/heap/heapam.c   (xmin/xmax/ctid, HOT)
WAL         backend/access/transam/xloginsert.c, xlog.c
commit      backend/access/transam/xact.c  (flush WAL, mark pg_xact)

Memorize these blocks:

MVCC: every tuple carries xmin and xmax; update equals new version plus xmax on the old; visibility is snapshot arithmetic; vacuum reclaims dead versions and freezes old ones; wraparound horizon is about two billion XIDs, with forced vacuums at 200 million by default.

WAL: log before page, always; commit equals WAL flushed through the commit record; 16 MB segments in pg_wal; checkpoints bound replay; one log serves recovery, replication, and PITR; replication is not backup.

Planner: paths costed, cheapest planned; seq_page_cost 1.0, random_page_cost 4.0, statistics from ANALYZE at target 100; read EXPLAIN ANALYZE by comparing estimated to actual rows first; costs are not milliseconds.

Processes and memory: postmaster forks one backend per connection; shared_buffers (default 128 MB, tune to about a quarter of RAM) plus the OS page cache double-buffer; checkpointer, background writer, autovacuum, and WAL senders are sibling processes; pool your connections.

Key takeaway: PostgreSQL is a small number of old, sound decisions compounding for decades: version rows instead of locking them and pay for it with vacuum, log every change once and derive recovery and replication from that log, isolate connections in processes, and keep the system's own behavior in catalogs so extensions can teach an ancient codebase new tricks. Follow one UPDATE from libpq to the WAL flush and you have touched every load-bearing wall in the building.