T1T2 · xid 101BEGIN ISOLATION LEVEL REPEATABLE READ;SELECT qty FROM stock;SELECT qty FROM stock;COMMIT;SELECT qty FROM stock;VACUUM1BEGIN;2UPDATE stock3 SET qty = 4;4COMMIT;snapshot–xid 100committedxid 101–v1 · ctid (0,1)v2 · ctid (0,2)32 bytes freexmin100xmax0qty5xmin101xmax0qty454
Back row: T1 (reader), T2 (writer) and VACUUMFront: the SQL cards, T1's snapshot, the commit log and page 0 with its row versionsEnvelopes: statements; discs: the value T1 readsAt the back: T1, a reader; T2, a writer; and VACUUM. In front: each session's SQL, T1's snapshot, the commit log, and page 0 of the stock table with its row versions (xmin, xmax, qty). An UPDATE adds a new version and stamps the old one's xmax; a commit only changes the commit log. T1's snapshot decides which version it sees, so it reads 5 until it takes a new snapshot and reads 4. The old version then dies, and VACUUM frees its space. Real transaction ids are much larger than 100 and 101; the figure keeps only their order and gaps. A transaction that only reads does not even get an id.

Versions, not overwrites

  1. PostgreSQL never changes a row in place. Each row version, a tuple, carries two stamps: xmin, the transaction that made it, and xmax, the one that replaced it (0: nobody yet). Here v1 holds qty 5, made by transaction 100.

Snapshots decide

Cleaning up

What happens

;; step 1/9 · versions
SELECT xmin, xmax, ctid, * FROM stock;
  xmin  xmax  ctid   id  qty
  100   0     (0,1)  1   5

every row version (tuple) has a header:
  xmin   the transaction that inserted it
  xmax   the transaction that deleted or replaced it
         (0 = nobody: this is the current version)
commit log:  100 committed

Real transaction ids are much larger than 100 and 101; the figure keeps only their order and gaps. A transaction that only reads does not even get an id. The visibility rule in the figure is simplified: the real check also handles a transaction seeing its own changes, subtransactions and locked rows.