System Design
T3

MVCC: row versions and snapshots

Postgres never overwrites a row. Each change writes a new version, and each reader picks the version its snapshot allows. Here are those versions on a real page, and the cleanup that versions need.

Not startedSaved in this browser only.
  1. 1An UPDATE writes a new row version and stamps the old one with xmax. Nothing changes in place.
  2. 2A snapshot decides which version each reader sees. Readers never wait for writers.
  3. 3VACUUM removes versions that no snapshot can see. The oldest open snapshot holds back cleanup for the whole database.
  4. 4Transaction IDs are 32 bits. VACUUM must freeze old rows before about 2 billion new IDs pass.
T3
    A

    The problem

    a reader and a writer on one row
    ✕ One copy, read locksWriterReportbalance = 100writeswaitsThe report waits untilthe writer commits.✓ Versions (MVCC)WriterReportv2: 150, newv1: 100, oldreads its snapshotBoth run at once.The report sees 100,its snapshot's version.
    • A report that waits for every writer stalls the writers behind it too.
    • Versions cost space and cleanup. That is the price of no read locks.

    With one copy of a row, a reader must wait for a writer. With versions, the reader takes the last committed version and nobody waits.

    B

    Ways to let readers and writers overlap

    where the old version lives
    methodwherestatus
    One copy, shared read locksLock-basedReaders wait
    Read uncommitted dataAnyNot approved
    Versions in the table, VACUUM removes old onesPostgresApproved
    Newest row in place, old values in an undo logMySQL InnoDBApproved
    A long open snapshot on a busy tablePostgresShort only

    Every MVCC design pays for old versions somewhere: dead rows in the table, or a growing undo log.

    Postgres keeps every version in the table and cleans up later. InnoDB keeps old values in an undo log. Both let readers skip locks.

    C

    Row versions

    the tuple header, recorded
    heap page 0, after UPDATE ... SET balance = 150 commitslpxminxmaxctidbalance(0,1)730731(0,2)100(0,2)731·(0,2)150old version:xmax = the updaternew version:xmin = the updaterxmin: the transaction that created this version.xmax: the transaction that deleted or replaced it.ctid: where the next version is; itself if it is the newest.
    • Each version has a 23-byte header before its data.
    • A DELETE only sets xmax. The version stays until VACUUM.
    • Two writers to one row still queue: the second waits for the first's transaction to end.

    Each version carries xmin, the transaction that created it, and xmax, the one that replaced it. ctid points to the next version.

    D

    Snapshots and visibility

    which version a reader sees
    snapshot = xmin : xmax : in-progress list, for example 741:745:741,743xmin 741xmax 745741742743744741 and 743: in progressolder IDsstarted laterA version is visible when its xmin committed and is in the snapshot,and its xmax is empty, aborted, or not in the snapshot.In the snapshot: below xmax and not in the in-progress list.Committed or aborted: read from the commit log.
    visibility checkpseudo code
    visible(version, snapshot):
      IF xmin is my own transaction:
        RETURN yes, unless I replaced it
      IF xmin aborted1, OR xmin running in the snapshot:
        RETURN no               // its creator had not committed
      IF xmax is empty, OR xmax aborted:
        RETURN yes              // nobody replaced it2
      IF xmax running in the snapshot:
        RETURN yes              // replaced, but not for this reader3
      RETURN no                 // replaced before the snapshot
    
    running in the snapshot:
      id >= snapshot.xmax, OR id in snapshot.in_progress4
    1. 1Committed or aborted comes from the commit log, then is cached on the version as a hint bit.
    2. 2The common case: the newest version of a row.
    3. 3The replacer was still running when the snapshot was taken, so this reader keeps the old version.
    4. 4Transactions that were running when the snapshot was taken stay invisible, even after they commit.
    Tested source Go: read every version on the page · Go: the claims each recorded run must meet
    Go: read every version on the pagego
    // Page reads every line pointer on page 0 of the accounts table with pageinspect. It sees all
    // row versions, including ones that no snapshot can see any more.
    func Page(ctx context.Context, conn *pgx.Conn) ([]Version, error) {
      rows, err := conn.Query(ctx, queries["heap_page"])
      if err != nil {
        return nil, fmt.Errorf("read heap page: %w", err)
      }
      return pgx.CollectRows(rows, func(r pgx.CollectableRow) (Version, error) {
        var v Version
        var flags, off, mask, mask2 int
        var balance []byte
        if err := r.Scan(&v.LP, &flags, &off, &v.Xmin, &v.Xmax, &v.Ctid, &mask, &mask2,
          &v.XminStatus, &v.XmaxStatus, &balance); err != nil {
          return v, fmt.Errorf("scan line pointer: %w", err)
        }
        v.State = []string{Unused, Normal, Redirect, Dead}[flags]
        if v.State == Redirect {
          v.To = off // a redirect stores the target line pointer in lp_off
        }
        if v.Xmax == "0" {
          v.Xmax = ""
        }
        if mask&heapXminFrozen == heapXminFrozen {
          v.XminStatus = "frozen"
        }
        v.HotUpdated = mask2&heapHotUpdated != 0
        v.HeapOnly = mask2&heapOnlyTuple != 0
        if len(balance) == 4 {
          b := int(int32(binary.LittleEndian.Uint32(balance)))
          v.Balance = &b
        }
        return v, nil
      })
    }
    
    Go: the claims each recorded run must meetgo
    // checkScenario asserts the claims the sheet makes about each run.
    func checkScenario(t *testing.T, id string, f []mvcc.Frame) {
      t.Helper()
      lp := func(fr mvcc.Frame, n int) mvcc.Version {
        for _, v := range fr.Page {
          if v.LP == n {
            return v
          }
        }
        return mvcc.Version{State: "missing"}
      }
      switch id {
      case "hot":
        upd := f[3]
        if v1, v2 := lp(upd, 1), lp(upd, 2); v1.Xmax == "" || v1.Xmax != v2.Xmin || !v1.HotUpdated || !v2.HeapOnly || v1.Ctid != "(0,2)" {
          t.Errorf("after the update: %+v and %+v, want a HOT chain (0,1) -> (0,2) with xmax = xmin", v1, v2)
        }
        if len(upd.Index) != 1 {
          t.Errorf("a HOT update added an index entry: %v", upd.Index)
        }
        if r := f[4]; r.Views[0].Balance != 100 || r.Views[0].Ctid != "(0,1)" {
          t.Errorf("the reader saw %+v during the update, want 100 from (0,1)", r.Views[0])
        }
        if r := f[6]; r.Views[0].Balance != 100 || r.Views[1].Balance != 150 {
          t.Errorf("after the commit: session 1 saw %d and session 2 saw %d, want 100 and 150", r.Views[0].Balance, r.Views[1].Balance)
        }
        if v := lp(f[7], 1); v.State != mvcc.Normal {
          t.Errorf("VACUUM with session 1 open left line pointer 1 %s, want the old version kept", v.State)
        }
        if v := lp(f[9], 1); v.State != mvcc.Redirect || v.To != 2 {
          t.Errorf("VACUUM after session 1 ended left line pointer 1 as %+v, want a redirect to 2", v)
        }
        if v := lp(f[10], 2); v.XminStatus != "frozen" {
          t.Errorf("VACUUM (FREEZE) left %+v, want a frozen xmin", v)
        }
      case "rollback":
        if v := lp(f[3], 2); v.XminStatus != "aborted" {
          t.Errorf("after ROLLBACK the new version is %+v, want an aborted xmin", v)
        }
        if r := f[4]; r.Views[0].Balance != 100 {
          t.Errorf("after ROLLBACK session 1 saw %d, want 100", r.Views[0].Balance)
        }
        // VACUUM frees the slot. A free slot at the end of the line pointer array is cut off.
        if v := lp(f[5], 2); v.State != mvcc.Unused && v.State != "missing" {
          t.Errorf("VACUUM left the aborted version as %s, want it freed", v.State)
        }
      case "indexed":
        if v := lp(f[0], 1); v.HotUpdated || len(f[0].Index) != 2 {
          t.Errorf("an update of an indexed column: %+v with index %v, want no HOT and 2 entries", v, f[0].Index)
        }
        if len(f[1].Index) != 1 {
          t.Errorf("after VACUUM the index has %v, want 1 entry", f[1].Index)
        }
      }
    }
    

    A snapshot is xmin, xmax and the list of running transactions. A version is visible when its creator committed before the snapshot and its replacer did not.

    E

    Try it: two sessions, one row, the real page

    recorded from Postgres with pageinspect
    Before the first statement: row 1 has one version.
    heap page 0, read with pageinspect
    lpread byxminxmaxctidbalance
    1S1S2730committed·(0,1)100

    primary key entries → (0,1)

    Session 1a new snapshot per statement
    snapshot
    731:731:
    finished
    IDs below 731
    reads
    (0,1), balance 100
    Session 2a new snapshot per statement
    snapshot
    731:731:
    finished
    IDs below 731
    reads
    (0,1), balance 100
    statement 0 of 11
    Step through the statements. Watch xmin, xmax and which version each session reads.

    Each run starts a fresh, private Postgres, so transaction IDs and snapshots match on every recording. A third connection reads the page after each statement.

    The reader keeps reading line pointer 1 while the writer creates line pointer 2. Neither waits. VACUUM removes line pointer 1 only after the reader's snapshot ends.

    F

    Capabilities used

    what Postgres gives you
    toolcapabilitywhat it gives youalso used for
    PostgresRow versions with xmin and xmaxReaders never block writers, and writers never block readers.Long reports on a busy table
    PostgresSnapshots: pg_current_snapshot()Each statement or transaction sees one consistent point in time.REPEATABLE READ, consistent backups with pg_dump
    PostgresCommit log and hint bitsA version's creator is committed or aborted; the answer is cached on the row.Instant ROLLBACK
    PostgresHOT updates and fillfactorAn update of unindexed columns writes no index entries.Counters, status columns
    PostgresVACUUM and autovacuumRemoves dead versions and records free space for reuse.Visibility map for index-only scans
    PostgresFreezingOld rows stay visible across transaction ID wraparound.Faster later scans of old pages
    Postgrespageinspect, pgstattupleRead raw versions, dead rows and free space.Diagnosing bloat
    Postgresidle_in_transaction_session_timeoutEnds a session that holds a snapshot and does nothing.Releasing locks
    PostgresOld versions in the tableLimit Every update writes a full row, and dead rows take space until VACUUM runs.

    Postgres gives me versioned rows, snapshots, HOT updates and VACUUM. I must keep transactions short so VACUUM can do its job.

    G

    HOT updates

    heap-only tuples, recorded
    ✓ HOT: unindexed column, room on the pageindex: 1 entrylp 1 → redirectlp 2: balance 150VACUUM keeps line pointer 1 as a redirect, so the indexentry still works. No index write at all.⚠ Indexed column changedprimary keyowner indexlp 1: old, deadlp 2: new2 entriesper index
    • Leave room on each page: fillfactor 70 to 90 for tables with many updates.
    • Do not index a column that changes often unless queries need it.
    • In our lab, 16 clients ran about 39,000 updates a second either way. HOT saves index growth and VACUUM work, not commit time.

    If no indexed column changes and the page has room, the update is HOT: no new index entries, and the old slot becomes a redirect.

    H

    VACUUM and autovacuum

    pseudo code
    vacuum one tablepseudo code
    vacuum(table):
      horizon = oldest xmin of every open snapshot1
      FOR EACH page that may hold dead versions2:
        dead = xmax committed before horizon, OR xmin aborted
        remove index entries that point at dead versions
        free their slots; a HOT chain keeps a redirect
      record the free space        // the next writes reuse it3
      freeze versions older than vacuum_freeze_min_age4
      cut empty pages off the end5 of the file
    1. 1One old snapshot in the database holds back cleanup in every table.
    2. 2The visibility map marks pages where every version is visible to all. VACUUM skips them.
    3. 3The table does not shrink. New versions fill the free space first.
    4. 450,000,000 transactions by default.
    5. 5Only trailing empty pages go back to the file system. VACUUM FULL rewrites the whole table.
    Tested source Go: the claims the bloat run must meet
    Go: the claims the bloat run must meetgo
    // The claims on the sheet.
    if upd.Dead != 100000 || upd.Bytes < load.Bytes*19/10 {
      t.Errorf("one update of every row: %d dead, %d bytes; want 100,000 dead and about twice %d bytes", upd.Dead, upd.Bytes, load.Bytes)
    }
    if vac.Dead != 0 || vac.Bytes != upd.Bytes {
      t.Errorf("VACUUM: %d dead and %d bytes, want 0 dead and the size unchanged at %d", vac.Dead, vac.Bytes, upd.Bytes)
    }
    if again := at("update-again"); again.Bytes != vac.Bytes {
      t.Errorf("the second update grew the table from %d to %d bytes; want it to reuse the freed space", vac.Bytes, again.Bytes)
    }
    if held := at("vacuum-held"); held.Dead != 100000 {
      t.Errorf("VACUUM with an open snapshot left %d dead versions, want all 100,000 kept", held.Dead)
    }
    if grown := at("update-held-again"); grown.Dead != 200000 || grown.Bytes < at("vacuum-held").Bytes*14/10 {
      t.Errorf("a second update with the snapshot open: %d dead, %d bytes; want 200,000 dead and a bigger table", grown.Dead, grown.Bytes)
    }
    if free := at("vacuum-free"); free.Dead != 0 {
      t.Errorf("VACUUM after the snapshot closed left %d dead versions, want 0", free.Dead)
    }
    if full := at("full"); full.Bytes != load.Bytes {
      t.Errorf("VACUUM FULL left %d bytes, want the loaded size %d", full.Bytes, load.Bytes)
    }
    settingdefault
    autovacuum_vacuum_threshold50
    autovacuum_vacuum_scale_factor0.2
    autovacuum_naptime60s
    autovacuum_vacuum_cost_delay2ms
    vacuum_cost_limit200
    autovacuum_max_workers3

    A 10 million row table waits for 2,000,050 dead rows: 50 + 0.2 × 10,000,000.

    VACUUM frees the space of versions that no open snapshot can see. Autovacuum runs it when dead rows pass 50 plus 20% of the table.

    I

    Bloat, measured

    pgstattuple after each step
    ledger, 100,000 rows: heap size after each stepLoad 100,000 rows: 14.1 MB, 100,000 live, 0 dead, 0.6% freeLoad 100,000 rows14.1Update every row: 28.3 MB, 100,000 live, 100,000 dead, 0.6% freeUpdate every row28.3VACUUM: 28.3 MB, 100,000 live, 0 dead, 50.1% freeVACUUM28.3Update every row again: 28.3 MB, 100,000 live, 100,000 dead, 0.6% freeUpdate every row again28.3VACUUM: 28.3 MB, 100,000 live, 0 dead, 50.1% freeVACUUM28.3Session 2 opens a REPEATABLE READ transaction and reads: 28.3 MB, 100,000 live, 0 dead, 50.1% freeSession 2 opens a snapshot28.3Update every row: 28.3 MB, 100,000 live, 100,000 dead, 0.6% freeUpdate every row28.3VACUUM, session 2 still open: 28.3 MB, 100,000 live, 100,000 dead, 0.6% freeVACUUM28.3Update every row again, session 2 still open: 42.4 MB, 100,000 live, 200,000 dead, 0.6% freeUpdate every row again42.4Session 2 commits: 42.4 MB, 100,000 live, 200,000 dead, 0.6% freeSession 2 commits42.4VACUUM: 42.4 MB, 100,000 live, 0 dead, 66.6% freeVACUUM42.4VACUUM FULL: 14.1 MB, 100,000 live, 0 dead, 0.6% freeVACUUM FULL14.1livedead versionsfree spaceMB; amber rows: session 2 holds a snapshot
    • Update every row once: 14.1 MB became 28.3 MB, with 100,000 dead versions.
    • VACUUM: 0 dead, same 28.3 MB, 50% free. The next update fit in that space.
    • VACUUM FULL: back to 14.1 MB. It rewrites the table and blocks every query while it runs.

    One update of every row doubled the table. VACUUM did not shrink it, but the next update reused the space. Only an open snapshot made the table grow again.

    J

    One long transaction

    the horizon stops
    VACUUM
    removed 0 of 100,000 dead versions while session 2 held a snapshot.
    growth
    one more update grew the table from 28.3 MB to 42.4 MB.
    after
    session 2 committed; VACUUM removed all 200,000.
    • An idle session in a transaction holds the horizon as well. Set idle_in_transaction_session_timeout.
    • A replica with hot_standby_feedback on holds it for its own queries.
    • An unused replication slot and an old prepared transaction hold it too.
    • Find the holder: the oldest backend_xmin in pg_stat_activity.

    An open snapshot keeps every version it might need. VACUUM cannot remove them, so the table grows until the transaction ends.

    K

    Wraparound and freezing

    32-bit transaction IDs
    left half: pastright half: future2^31 ≈ 2.1 billion eachnow: the next transaction ID.age 200 millionAutovacuum must freeze the table,even with autovacuum turned off.age 1.6 billionFailsafe: VACUUM skips index work andits cost delay to finish the freeze.about 3 million IDs before 2^31Postgres refuses new transaction IDsuntil a VACUUM freezes the oldest rows.
    • A frozen row is visible to every snapshot. The recorded run shows it after VACUUM (FREEZE).
    • Watch age(datfrozenxid) per database. Alert well before 200,000,000.
    • At 10,000 transactions a second, 2.1 billion IDs last about 2.5 days.

    Transaction IDs wrap around after about 4 billion, so each row can be at most 2 billion transactions old. VACUUM freezes rows long before that.

    L

    Undo logs: a MySQL contrast

    MySQL InnoDB, not Postgres
    Postgrestable (heap)v1: 100, xmax = 731v2: 150, xmin = 731Old versions stay in the table.VACUUM removes them later.ROLLBACK writes almost nothing.MySQL InnoDBtable (clustered index)row: 150, trx id, roll ptrundo log: balance was 100The row changes in place; old valuesgo to the undo log. An old snapshotrebuilds its version from it. Purgeremoves undo that no snapshot needs.MySQL 8.0 manual; not measured
    • InnoDB: a long transaction makes the undo history grow, and purge falls behind.
    • InnoDB: a rollback must apply undo records, so a large rollback is slow.
    • Postgres: a rollback is instant, but the dead versions stay until VACUUM.

    Source: the MySQL 8.0 reference manual, InnoDB multi-versioning and undo logs. Not measured in our lab.

    InnoDB keeps the newest row in place and old values in an undo log. Postgres keeps all versions in the table and cleans up with VACUUM.

    M

    Failure cases

    what breaks, and what stops it
    eventresultwhat stops itsaved by
    A session stays idle inside a transaction for hoursVACUUM removes nothing newer than its snapshot. Tables and indexes grow.idle_in_transaction_session_timeout ends it. Alert on the oldest backend_xmin.Idle timeout
    A long report on the primarySame: the horizon stops for the whole database.Run reports on a replica. Watch hot_standby_feedback, which sends the horizon back.Replica
    Autovacuum cannot keep up with a hot tableDead rows pile up; scans slow down.Lower the table's scale factor, raise its cost limit. Our lab removed 60,000 versions a second at the default throttle.Per-table settings
    Every update changes an indexed columnNo HOT. Every index grows with every update.Drop indexes that queries do not use. Leave free space with fillfactor.HOT
    Transaction IDs near wraparoundPostgres refuses new transaction IDs. Writes stop.Autovacuum freezes at age 200,000,000. Do not cancel those runs.Freeze
    An unused replication slot or old prepared transactionIt holds the horizon forever.Drop the slot; commit or roll back the prepared transaction.Monitoring
    The table is already bloatedSpace is not returned to the file system.VACUUM FULL takes ACCESS EXCLUSIVE. Use an online rewrite tool, or partition and drop.Rewrite
    N

    Scale ladder

    start with autovacuum; climb on a signal
    Each step adds one tool1autovacuum2+ short txns3+ per-table tuning4+ HOT, fillfactor5+ partitionsmore load →
    Row versions per second, one table1k10k100k1M10MCreated, 16 updaters: 38,754 row versions per secondCreated, 16 updaters38,754Removed, autovacuum throttle: 59,969 row versions per secondRemoved, autovacuum throttle59,969Removed, VACUUM unthrottled: 889,286 row versions per secondRemoved, VACUUM unthrottled889,286row versions per second, log scale
    stepaddit handlesmove up when you see
    1Autovacuum on, default settings.Most tables. At the default throttle our lab removed about 60,000 dead versions a second.Dead rows grow while one session stays open: the horizon is stuck.
    2Short transactions: idle_in_transaction_session_timeout, statement_timeout, reports on a replica.The horizon moves, so every VACUUM can remove what is dead.One hot table's dead rows still pass 20% before autovacuum starts.
    3Per-table autovacuum settings: lower scale factor, higher cost limit.Unthrottled, VACUUM removed about 890,000 versions a second, 15 times the default rate.Indexes grow faster than the table: updates are not HOT.
    4HOT: fillfactor below 100, no index on columns that change often.Updates write no index entries. In our lab, 100% of balance updates were HOT at fillfactor 80.Old rows are deleted in bulk by date, and VACUUM must scan them all.
    5Partitions by time. Drop or detach a whole partition.Dropping a partition leaves no dead rows at all.Top of the ladder.

    Created: 16 clients updating random rows of a 100,000-row table. Removed: VACUUM of 1,000,000 dead versions, unthrottled and with the autovacuum cost settings. When creation passes removal, dead rows pile up.

    I keep autovacuum on with defaults and transactions short. I tune per table when dead rows grow, and I partition when deletes by age dominate.

    O

    Drill

    predict, then reveal

    0 of 9 known

    1. An UPDATE changes one column of one row. What does Postgres write to the table?

    2. A long report reads a row while another transaction updates it. Does either wait?

    3. A snapshot is 731:731:. Transaction 731 then updates a row and commits. What does this snapshot see?

    4. Session 1 holds a REPEATABLE READ snapshot. You run VACUUM. Why does the old version stay?

    5. A transaction rolls back after it updated 1 million rows. How long does the ROLLBACK take?

    6. When is an update HOT, and what does it save?

    7. After VACUUM the table is still 28 MB. Is VACUUM broken?

    8. Why must Postgres freeze old rows?

    9. How does MySQL InnoDB keep old versions?

    P

    Numbers to say

    measured in the lab
    update
    One update of every row doubled the table: 14.1 MB to 28.3 MB.
    VACUUM
    About 890,000 dead versions a second unthrottled; about 60,000 at the autovacuum throttle.
    trigger
    Autovacuum starts at 50 + 20% of rows dead.
    header
    23 bytes per row version, plus a 4-byte line pointer.
    freeze
    Forced at age 200,000,000. Wraparound at about 2.1 billion.
    long txn
    Held 200,000 dead versions and grew the table by half.

    Postgres 16 on an 8-core laptop. Bloat runs on a private server with autovacuum off; rates on the lab server, 16 clients, 3-second runs. Use these as orders of magnitude.