Filter concepts by levelShowing all levels.

PostgreSQL · MVCC

MVCC — Must Understand

Multi-Version Concurrency Control end to end: new row versions instead of in-place writes, snapshots and visibility, why readers and writers never block each other, dead tuples, and how VACUUM actually reclaims them

  • New version, not overwriteUPDATE/DELETE set xmax, never erase
  • Readers never block writersand vice versa — the core MVCC guarantee
  • Dead tuplesuperseded, no snapshot needs it — reclaimable
  • VACUUM reuses, not shrinksVACUUM FULL is the rare, blocking exception

1.Multi-Version Concurrency Control

MVCC is the mechanism that lets PostgreSQL give every statement a consistent snapshot of the database without locking readers against writers — instead of one shared copy of each row that everyone fights over, PostgreSQL keeps multiple versions of a row around, and each transaction sees exactly the version(s) that existed as of its own snapshot.

Key features

  • Each SQL statement sees a snapshot of the database as it existed at some point, regardless of what has changed since
  • Locks acquired for reading do not conflict with locks acquired for writing — reading never blocks writing, and writing never blocks reading
  • This works because a writer creates a new row version rather than overwriting the old one in place — a concurrent reader can keep using the old version undisturbed
  • A transaction snapshot determines which row versions are visible to that transaction for its whole duration (Repeatable Read/Serializable) or per-statement (Read Committed)
  • PostgreSQL achieves even its strictest isolation level (Serializable) through MVCC-based mechanisms (Serializable Snapshot Isolation) rather than falling back to heavy locking

MVCC vs a naive single-copy-per-row model

MVCC vs a naive single-copy-per-row model
PropertyNaive single-copy modelMVCC
Does a reader block a writer?often, yesnever
Does a writer block a reader?often, yesnever
What a writer changesthe one existing copy, in placecreates a new version alongside the old
What a reader seeswhatever the current copy holdsthe version consistent with its own snapshot
A reader and a writer, never blocking each other
still visible tothis snapshotUPDATE creates a newversion, does not overwrite

Row version 1

existed before the UPDATE

Reader (old snapshot)

keeps seeing version 1

Row version 2

created by a concurrent UPDATE

  • Row version 1 — existed before the UPDATE
    • leads to Reader (old snapshot) (still visible to this snapshot)
    • leads to Row version 2 (UPDATE creates a new version, does not overwrite)
  • Reader (old snapshot) — keeps seeing version 1
  • Row version 2 — created by a concurrent UPDATE

Command

sql
-- MVCC in one sentence, demonstrated:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- snapshot taken here
-- (a concurrent transaction commits an UPDATE to this row)
SELECT balance FROM accounts WHERE id = 1;  -- still sees the OLD version -- no lock, no block
COMMIT;

MVCC gives every statement a consistent snapshot without locking readers against writers, by having a writer create a new row version instead of overwriting the old one in place — reading never blocks writing and writing never blocks reading. It does NOT mean writer-vs-writer conflicts on the same row stop existing; those still block as usual.

2.Updates Create New Row Versions

An UPDATE in PostgreSQL does not modify the existing row in place — it writes an entirely new row version and marks the old one as superseded (by setting its xmax to the updating transaction's id), leaving both versions physically present on disk until VACUUM eventually reclaims the old one. A DELETE works the same way: it marks the row as superseded rather than immediately erasing its bytes.

Key features

  • UPDATE creates a new row version (new xmin = the updating transaction) and sets xmax on the old version to the same transaction id
  • DELETE sets xmax on the existing row version — it does not erase the row's bytes immediately
  • A row version whose xmax is set and committed is only invisible to snapshots taken after that commit; older snapshots can still legitimately see it
  • Both the old and new versions occupy real disk space until VACUUM reclaims the old, no-longer-visible-to-anyone version
  • This is why an UPDATE — even one that changes nothing about a row's size — can still require rewriting the entire row and can increase a table's total size

What actually happens to a row

What actually happens to a row
System columnWhat it records
xminthe id of the transaction that created (inserted or updated-into-existence) this row version
xmaxthe id of the transaction that superseded this version (via UPDATE or DELETE), or unset if still current
UPDATE writes a new version instead of overwriting in place
insertsmarksold xmax

Old row version

xmin=501, xmax=0, balance=1000

UPDATE runs

balance = 900

New row version

xmin=502, xmax=0, balance=900

Old version superseded

xmax set to 502 — dead once no snapshot needs it

  • Old row version — xmin=501, xmax=0, balance=1000
    • leads to UPDATE runs
  • UPDATE runs — balance = 900
    • leads to New row version (inserts)
    • leads to Old version superseded (marks old xmax)
  • New row version — xmin=502, xmax=0, balance=900
  • Old version superseded — xmax set to 502 — dead once no snapshot needs it

Command

sql
-- xmin/xmax are hidden system columns, visible if selected explicitly
SELECT xmin, xmax, * FROM accounts WHERE id = 1;

UPDATE accounts SET balance = 900 WHERE id = 1;
-- old row version: xmax is now set to this transaction's id
-- new row version: xmin is set to this transaction's id, xmax unset

UPDATE creates a new row version (new xmin) and marks the old one superseded (sets its xmax) — it never overwrites the old bytes in place. Both versions occupy real space until VACUUM reclaims the old one, which is why even a no-op UPDATE still creates a new version and contributes to bloat.

3.Transaction Snapshots and Visibility

A snapshot is the specific rule PostgreSQL uses to decide, for any given row version, whether the current statement or transaction is allowed to see it — based on comparing that version's xmin/xmax against which transactions were committed at the moment the snapshot was taken. Under Read Committed, a fresh snapshot is taken for every statement; under Repeatable Read and Serializable, one snapshot is taken for the whole transaction and reused for every statement in it.

Key features

  • A snapshot records, in effect, which transaction ids were already committed at the moment it was taken
  • A row version is visible to a snapshot if its xmin was committed by then, and its xmax (if set) was not
  • Read Committed takes a new snapshot for every individual statement
  • Repeatable Read and Serializable take one snapshot at the start of the transaction and reuse it for every statement inside it
  • Because visibility is computed by comparing static xmin/xmax values against a snapshot, no lock is needed just to determine whether a row is visible

When a fresh snapshot is taken

When a fresh snapshot is taken
Isolation levelNew snapshot taken
Read Committed (default)at the start of every statement
Repeatable Readonce, at the start of the transaction
Serializableonce, at the start of the transaction (plus conflict tracking)
When each isolation level takes its snapshot
  1. Read Committed

    new snapshot every statement

    each SELECT sees the latest committed data as of that statement

  2. Repeatable Read

    one snapshot, taken at BEGIN

    reused for every statement in the transaction — no intervening commit becomes visible

  3. Serializable

    one snapshot, plus conflict tracking

    same snapshot scope as Repeatable Read, with SSI predicate locking added

  1. Read Committed: new snapshot every statement — each SELECT sees the latest committed data as of that statement
  2. Repeatable Read: one snapshot, taken at BEGIN — reused for every statement in the transaction — no intervening commit becomes visible
  3. Serializable: one snapshot, plus conflict tracking — same snapshot scope as Repeatable Read, with SSI predicate locking added

Command

sql
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT * FROM accounts;  -- snapshot taken HERE, reused for the rest of this transaction
-- every later statement in this transaction uses the SAME snapshot
COMMIT;

A snapshot is lightweight bookkeeping — which transaction ids were committed as of a moment — checked against each row version's xmin/xmax to decide visibility, not a copy of the data. Read Committed takes a fresh one per statement; Repeatable Read/Serializable take one for the whole transaction, which is the entire mechanical reason for their different behavior.

4.Why Readers Generally Do Not Block Writers, and Vice Versa

A plain SELECT never needs to acquire a lock that would conflict with a concurrent UPDATE, because it is not reading "the current row" in a way that could be disturbed by a concurrent change — it is reading whichever row version its own snapshot says is visible, which a writer creating a new version does not touch or invalidate. That is the direct mechanical reason readers and writers do not block each other under MVCC.

Key features

  • A SELECT reads whichever row version is visible to its own snapshot — a concurrent UPDATE creating a new version does not touch that version at all
  • This is why a long-running report query does not block ordinary application writes, and vice versa, under normal operation
  • The "generally" qualifier matters: DDL statements (ALTER TABLE, DROP TABLE, etc.) do take locks that can block readers — MVCC's guarantee applies to ordinary DML, not schema changes
  • Two genuine writers to the SAME row still conflict and one must wait — MVCC solves the reader-vs-writer case, not the writer-vs-writer case
  • This property is why PostgreSQL does not need a "read lock" concept the way some other systems do for basic SELECT statements

What blocks what, under ordinary DML

What blocks what, under ordinary DML
Operation AOperation BBlocks?
SELECTconcurrent UPDATE (same row)no
UPDATEconcurrent SELECT (same row)no
UPDATEconcurrent UPDATE (same row)yes — genuine writer conflict
SELECTconcurrent ALTER TABLE (same table)yes — DDL is the exception

Command

sql
-- Session A: a long-running report query
SELECT sum(balance) FROM accounts;  -- takes 30 seconds on a huge table

-- Session B, meanwhile, is completely unblocked:
UPDATE accounts SET balance = balance - 100 WHERE id = 1;  -- succeeds immediately

A reader sees whichever row version its snapshot says is visible; a writer creates a new version without touching that one — so there is nothing to protect either from, and no lock is needed between them. This applies to ordinary DML, not DDL: ALTER TABLE and similar schema changes still take locks that CAN block a concurrent reader.

5.Dead Tuples

A dead tuple is a row version whose xmax is set and that committed change is no longer needed by any active snapshot — it is the leftover, no-longer-visible-to-anyone-at-all remnant of a row that an UPDATE or DELETE superseded. It still occupies physical space on disk until VACUUM comes along and reclaims it.

Key features

  • A dead tuple is a row version with xmax set, where the superseding transaction has committed AND no open snapshot could still need the old version
  • UPDATE and DELETE both produce dead tuples — UPDATE indirectly (the old version becomes dead once superseded and no longer needed), DELETE directly
  • A dead tuple still occupies real disk space — INSERT-heavy tables grow from new rows, but UPDATE/DELETE-heavy tables can grow just as much from accumulating dead tuples
  • Dead tuples are exactly what VACUUM exists to find and reclaim, making their space available for reuse by future INSERTs/UPDATEs
  • A long-running or idle-in-transaction session holds back the point at which a superseded row version can be considered dead, letting dead tuples pile up even on unrelated tables

Row version states

Row version states
StateMeaning
Livevisible to at least one current or future snapshot
Superseded, not yet deadxmax is set, but some open snapshot could still legitimately need it
Deadxmax committed, and no open snapshot could ever need it — reclaimable by VACUUM
A row version's lifecycle from live to reclaimed
UPDATE orDELETElast needingsnapshot closesVACUUM

Live

start

Superseded, not yet dead

Dead tuple

Reclaimed

end

  • Live (start)
    • → Superseded, not yet dead when UPDATE or DELETE
  • Superseded, not yet dead
    • → Dead tuple when last needing snapshot closes
  • Dead tuple
    • → Reclaimed when VACUUM
  • Reclaimed (end)

Command

sql
-- see the dead tuple count for a table
SELECT relname, n_dead_tup, n_live_tup
  FROM pg_stat_user_tables
 WHERE relname = 'accounts';

VACUUM accounts;  -- reclaims space from dead tuples

A dead tuple is a superseded row version (xmax set, superseding transaction committed) that no open snapshot could still need — it still occupies disk space until VACUUM reclaims it. n_dead_tup in pg_stat_user_tables is the direct, observable count. A long-running transaction holds back the point at which a superseded version can be considered dead.

6.How VACUUM Removes Obsolete Row Versions

VACUUM scans a table for dead tuples (row versions no open snapshot could still need), marks their space as available for reuse by future writes, and updates the table's free space map — but it does NOT shrink the table file or return space to the operating system, except in the narrow case where entirely-empty pages sit at the very end of the file. VACUUM FULL is the separate, much heavier operation that actually rewrites the table to its minimum size and returns space to the OS.

Key features

  • Standard VACUUM removes dead tuples and marks their space reusable within the table file — it does not shrink the file
  • The one exception: if entirely-empty pages happen to sit at the very end of the file and an exclusive lock is easy to get, VACUUM can truncate and return that space to the OS
  • VACUUM FULL rewrites the entire table into a new, compact file with no dead space, then swaps it in — this DOES return space to the OS, but requires an ACCESS EXCLUSIVE lock (blocking all other access) and extra disk space during the operation
  • The routine goal is running standard VACUUM often enough that VACUUM FULL is rarely, if ever, needed
  • VACUUM also updates the visibility map and free space map, both of which speed up future scans and future writes respectively

VACUUM vs VACUUM FULL

VACUUM vs VACUUM FULL
PropertyVACUUMVACUUM FULL
Reclaims dead tuple spaceyes, for reuse within the tableyes, by rewriting the whole table
Shrinks the file / returns space to OSonly trailing empty pagesyes, always
Lock requirednone that blocks ordinary reads/writesACCESS EXCLUSIVE — blocks everything
Extra disk space needednoyes, for the new copy, until it completes

Command

sql
VACUUM accounts;        -- routine: reclaims space for reuse, no exclusive lock
VACUUM FULL accounts;    -- heavy: rewrites the table, returns space to the OS, exclusive lock

Standard VACUUM reclaims dead tuple space for REUSE within the table — it does not shrink the file or return space to the OS (except trailing empty pages). VACUUM FULL rewrites the whole table smaller and does return space to the OS, but needs an ACCESS EXCLUSIVE lock that blocks everything — reserve it for rare cases, not routine maintenance.

Remember

  • MVCC gives every statement a consistent snapshot by versioning rows instead of locking readers against writers.
  • UPDATE creates a new row version (new xmin) and marks the old one superseded (sets its xmax) — never an in-place overwrite.
  • A snapshot is lightweight bookkeeping — which xids were committed as of a moment — not a copy of the data.
  • Readers and writers never block each other on ordinary DML because they are never touching the same mutable state — DDL is the documented exception.
  • A dead tuple still occupies disk space until VACUUM reclaims it for reuse — VACUUM FULL is the separate, rare operation that actually shrinks the file.

Pro tip

  • SELECT xmin, xmax, * FROM t; makes MVCC's row-versioning directly observable, not just theoretical.
  • n_dead_tup in pg_stat_user_tables is the direct, live count of accumulated cleanup work.
  • Never run VACUUM FULL as routine maintenance — its ACCESS EXCLUSIVE lock blocks everything for its full duration.

Next up

  • VACUUM, ANALYZE and Autovacuum
  • Storage and Table Internals
  • Index Fundamentals

Sources

  • MVCC Introduction — postgresql.org/docs/17/mvcc-intro.html
  • Database Page Layout — postgresql.org/docs/17/storage-page-layout.html
  • Routine Vacuuming — postgresql.org/docs/17/routine-vacuuming.html
  • Every example executed on PostgreSQL 17.11 · checked 2026-08-24

Spotted a mistake on this page? Tell us.