Skip to content

How PostgreSQL delivers ACID: WAL and MVCC row versions

An UPDATE adds a row version and never overwrites one; snapshots let readers and writers pass, and VACUUM clears only versions nobody can still see.

Predict first

Before you read: predict

Before an ECON-201 sitting, the proctor console's live feed is rehearsed on a test copy of candidate_status that holds only candidate c-41's row. It changes c-41's status three times: to "in room", then "writing", then "submitted". Each change is its own committed UPDATE. Nothing else touches the table, and no VACUUM has run since the row was inserted.

How many copies of c-41's row, current or not, does the table's storage hold now?

Pick one

The 30-second version

Postgres never changes a row in place. An UPDATE writes a new row version stamped with its transaction's id and marks the old one replaced. A query reads a snapshot, which picks the versions committed when it began, so readers and writers never wait for each other. VACUUM removes versions no open transaction can still see.

An UPDATE adds a row version and never overwrites one; snapshots let readers and writers pass, and VACUUM clears only versions nobody can still see.

How it works

Step 1 of 10

A transaction is atomic: its changes become visible together at COMMIT, or, after ROLLBACK or a crash, not at all.

Analogy: A film editor's cutting room

The editor never splices a reel in place. Each recut is printed as a new reel, and the old reel is labelled with the cut that replaced it. Screening rooms run all day, and the vault clerk recycles old reels.

In the cutting room In Postgres
a recut printed as a new reel; the old reel labelled "replaced by cut 7" an UPDATE's new version; xmax on the old one (step 2)
each reel's label: the cut that made it, the cut that replaced it xmin and xmax (step 3)
a screening shows the reels that were final when it started a query's snapshot (step 4)
the editor recuts during a screening; neither stops for the other readers and writers do not block (step 5)
an abandoned cut's reel goes straight to the bin ROLLBACK leaves dead versions (step 6)
every splice first written in the edit log in the fireproof safe the WAL (step 7)
the clerk recycles replaced reels no running screening could show VACUUM (steps 8–9)
a screening room left running all night with the door locked a forgotten open transaction pinning versions (step 10)

In real terms: a change adds a version and labels the old one; each query picks versions by its snapshot, and VACUUM reclaims the ones no open snapshot or writer can still need.

Breaks down when: a clerk can look into a screening room and see which reels are on the projector. VACUUM cannot see what an open transaction will read next, so it keeps every version that transaction might still see. Wrong inference: "VACUUM keeps only the versions an open transaction has actually read." It keeps them all, in every table.

Contrasting case: one tape edited while it is on air: every viewer waits for each splice. That is a lock without versions, which Postgres uses only when asked (FOR UPDATE, in the lost-update pack).

Examples, run for real

4.1 ExamHall: the report that never committed

EXH-1810: during an ECON-201 sitting, the proctor console's live feed updated five candidates' status rows twelve times each. The nightly integrity report had opened a transaction, written its run row, and hung on an export without committing.

EXH-1810 report open (idle in transaction, holds a transaction id: true) after 60 status updates
  VACUUM tuples: 0 removed, 65 remain, 60 are dead but not yet removable
report transaction ended
  VACUUM tuples: 60 removed, 5 remain, 0 are dead but not yet removable

The report had written, so every version that died after that stayed (steps 9–10), although it never read candidate_status. Ending it freed all 60. MR !2010 makes the report commit its run row at once, and gives the report_bot role idle_in_transaction_session_timeout, so the server ends a forgotten transaction itself:

MR !2010 report_bot idle_in_transaction_session_timeout = 1s; the same hang, 60 more updates
  server ended the idle transaction: true · VACUUM tuples: 60 removed, 0 are dead but not yet removable

4.2 Proof domain, e-commerce catalog: one price change on the page

A catalog admin changes kettle-3's price from 40 to 45. The script reads the table's page with pageinspect at each moment; transaction ids are shown as T1, T2.

kettle-3 inserted          page: (0,1) xmin T1 xmax 0
UPDATE sent, not committed page: (0,1) xmin T1 xmax T2 | (0,2) xmin T2 xmax 0
  WAL insert position moved forward during the UPDATE: true
COMMIT                     page: (0,1) xmin T1 xmax T2 | (0,2) xmin T2 xmax 0 · SELECT returns price 45
VACUUM                     page: (0,2) xmin T2 xmax 0 · tuples: 1 removed, 1 remain

The UPDATE wrote a second version and stamped T2 into the first one's xmax (step 2). The WAL grew before anything committed (step 7). After COMMIT a query returns only the new price, but both versions stay on the page until VACUUM removes the dead one (step 8).

Picture it

two rounded charcoal-outlined trays side by side on an off-white background, one lavender and one pale cyan, each holding a stack of two lime discs (opened 2026-09-29). Nothing is labelled.
An illustration to help it stick. The mechanism is in the sections above.

Try it

Answer first, then check. Nothing is saved.

Question 1 of 5

A catalog admin opens a transaction and changes mug-12's price from 12 to 14, and has not committed. A shopper's product page then reads mug-12's price on another connection.

What does the page get?

Pick one

Question 2 of 5

A price import raises all 40 prices in a small products table of mugs in one transaction, then hits an error and rolls back. Nothing else has touched the table since its last VACUUM. The next VACUUM of products runs.

How many row versions does it remove?

Pick one

Question 3 of 5

At 10:00 the nightly price sync changed all 30 prices of the lighting range in products and committed. At 12:00 VACUUM (VERBOSE) products removes none of the sync's old versions. These sessions are connected to the same database.

Which one keeps VACUUM from removing them?

Pick one

Question 4 of 5

A colleague doubts that Postgres writes a new version when it runs an UPDATE. On a quiet copy of products, you will run one plain query before and after UPDATE products SET stock = 8 WHERE sku = 'lamp-7' commits, and compare.

Which observation shows that a new version was written?

Pick one

Question 5 of 5

Each night a revenue report over orders runs as a single query for about three hours on the primary. While it runs, VACUUM (VERBOSE) products reports versions that are "dead but not yet removable", and the product pages slow down.

Which change lets VACUUM reclaim those versions while the report is still being produced?

Pick one

Go deeper

Pro tips
  1. Find what holds VACUUM back by backend_xid and backend_xmin, not by state. SELECT pid, application_name, state, backend_xid, backend_xmin, xact_start FROM pg_stat_activity WHERE backend_xid IS NOT NULL OR backend_xmin IS NOT NULL ORDER BY xact_start; When: VACUUM VERBOSE reports "dead but not yet removable" (steps 9–10). Verify (RAN, tips.mjs, 50 + 50 runs, 1 distinct):
    tip1 idle-connection    idle                 backend_xid set false backend_xmin set false -> holds back VACUUM: false
    tip1 reader-idle-in-tx  idle in transaction  backend_xid set false backend_xmin set false -> holds back VACUUM: false
    tip1 running-query      active               backend_xid set false backend_xmin set true  -> holds back VACUUM: true
    tip1 writer-idle-in-tx  idle in transaction  backend_xid set true  backend_xmin set false -> holds back VACUUM: true
    tip1 check: all four open -> 1 not removable · writer ended -> 2 · running query ended too -> 0 (reader and idle connection still open)
    An idle-in-transaction session that has only read, at the default level, held nothing; a session with neither column set pins nothing.
  2. Set idle_in_transaction_session_timeout on the role that logs in. ALTER ROLE … SET applies when that role logs in, not after SET ROLE from another login. When: guarding a reporting or batch role (step 10, MR !2010). Verify (RAN, tips.mjs):
    tip2 postgres, then SET ROLE report_bot: 0 · logged in as report_bot: 5min
Source
  • PostgreSQL 17 documentation, 13.1 Introduction (Concurrency Control): https://www.postgresql.org/docs/17/mvcc-intro.html · fetched 2026-09-29. "each SQL statement sees a snapshot of data (a database version) as it was some time ago, regardless of the current state of the underlying data"; "locks acquired for querying (reading) data do not conflict with locks acquired for writing data, and so reading never blocks writing and writing never blocks reading". Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 5.6 System Columns: https://www.postgresql.org/docs/17/ddl-system-columns.html · fetched 2026-09-29. xmin: "The identity (transaction ID) of the inserting transaction for this row version. (A row version is an individual state of a row; each update of a row creates a new row version for the same logical row.)"; xmax: "The identity (transaction ID) of the deleting transaction, or zero for an undeleted row version"; ctid: "The physical location of the row version within its table … a row's ctid will change if it is updated or moved by VACUUM FULL". Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 24.1.2 Recovering Disk Space (Routine Vacuuming): https://www.postgresql.org/docs/17/routine-vacuuming.html · fetched 2026-09-29. "an UPDATE or DELETE of a row does not immediately remove the old version of the row"; "the row version must not be deleted while it is still potentially visible to other transactions"; "The standard form of VACUUM removes dead row versions in tables and indexes and marks the space available for future reuse. However, it will not return the space to the operating system, except in the special case…"; VACUUM FULL and the rewriting commands "require an ACCESS EXCLUSIVE lock". Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 28.3 Write-Ahead Logging (WAL) (chapter 28, Reliability and the Write-Ahead Log): https://www.postgresql.org/docs/17/wal-intro.html · fetched 2026-09-29. "changes to data files (where tables and indexes reside) must be written only after those changes have been logged, that is, after WAL records describing the changes have been flushed to permanent storage"; "in the event of a crash we will be able to recover the database using the log: any changes that have not been applied to the data pages can be redone from the WAL records"; "only the WAL file needs to be flushed to disk to guarantee that a transaction is committed". Supports step 7 (cited, not executed: no crash was run). Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 28.4 Asynchronous Commit: https://www.postgresql.org/docs/17/wal-async-commit.html · fetched 2026-09-29. "transaction commit is normally synchronous: the server waits for the transaction's WAL records to be flushed to permanent storage before returning a success indication to the client"; asynchronous commit risks "data loss, not data corruption". Supports step 7 and card 7 ("with the default settings"). Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 3.4 Transactions (tutorial): https://www.postgresql.org/docs/17/tutorial-transactions.html · fetched 2026-09-29. A transaction "bundles multiple steps into a single, all-or-nothing operation"; "The updates made so far by an open transaction are invisible to other transactions until the transaction completes, whereupon all the updates become visible simultaneously". Supports step 1. Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 19.11 Client Connection Defaults, idle_in_transaction_session_timeout: https://www.postgresql.org/docs/17/runtime-config-client.html · fetched 2026-09-29. "Terminate any session that has been idle (that is, waiting for a client query) within an open transaction for longer than the specified amount of time"; "an open transaction prevents vacuuming away recently-dead tuples that may be visible only to this transaction". Supports step 10, MR !2010, tip 2. Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 65.7 Heap-Only Tuples (HOT): https://www.postgresql.org/docs/17/storage-hot.html · fetched 2026-09-29. "When a row is updated multiple times, row versions other than the oldest and the newest can be completely removed during normal operation, including SELECTs, instead of requiring periodic vacuum operations." Supports step 8's pruning clause and the opening question's one-row stem; that pruning waits for a page short of free space is observed, not quoted. Valid as of 2026-09-29.
  • PostgreSQL 17 documentation, 27.2 The Cumulative Statistics System, pg_stat_activity: https://www.postgresql.org/docs/17/monitoring-stats.html · fetched 2026-09-29. backend_xid: "Top-level transaction identifier of this backend, if any"; backend_xmin: "The current backend's xmin horizon". Supports tip 1. Valid as of 2026-09-29.

Checked against the tools and docs as of 2026-09-29.