Filter concepts by levelShowing all levels.

PostgreSQL · Section 17

MVCC — Must Understand

Level
intermediate
Read
32 min
Concepts
6

Multi-Version Concurrency Control end to end: what MVCC actually is and the non-blocking guarantee it exists to provide, the physical mechanism underneath it (UPDATE creates a new row version rather than overwriting in place), how a transaction snapshot uses that versioning to decide what is visible, why readers and writers never block each other on ordinary DML (and the documented DDL exception), what a dead tuple is and how it relates to the transaction-duration risk covered earlier, and finally how VACUUM actually reclaims that space — for reuse, not by shrinking the file, with VACUUM FULL as the separate, heavier, rarely-needed exception.

PostgreSQL overview

What is true here

  1. MVCC gives every statement a consistent snapshot by versioning rows instead of locking readers against writers.
  2. UPDATE creates a new row version (new xmin) and marks the old one superseded (sets its xmax) — never an in-place overwrite.
  3. A snapshot is lightweight bookkeeping checked against each row version's xmin/xmax, not a copy of the data.
  4. Readers and writers never block each other on ordinary DML — DDL like ALTER TABLE is the documented exception.
  5. A dead tuple is a superseded row version no open snapshot needs anymore — VACUUM reclaims it for reuse, not by shrinking the file (VACUUM FULL is the rare exception that does).

What you will be able to do

  • Explain MVCC's non-blocking read/write guarantee and its documented DDL exception
  • Observe row versioning directly via xmin/xmax and explain why even a no-op UPDATE creates a new version
  • Explain the mechanical difference between Read Committed and Repeatable Read purely in terms of snapshot scope
  • Distinguish a dead tuple from a merely-superseded one, and explain what VACUUM vs VACUUM FULL actually does to reclaim it
From a write to reclaimed space
once superseded andunneeded by any snapshotthe cleanupstep

UPDATE/DELETE

new version created, old one superseded

Dead tuple

once no snapshot needs it anymore

VACUUM reclaims it

space freed for reuse, not shrunk

  • UPDATE/DELETE — new version created, old one superseded
    • leads to Dead tuple (once superseded and unneeded by any snapshot)
  • Dead tuple — once no snapshot needs it anymore
    • leads to VACUUM reclaims it (the cleanup step)
  • VACUUM reclaims it — space freed for reuse, not shrunk

The mechanism

What MVCC is, and the physical row-versioning underneath it.

Multi-Version Concurrency Control

coreintermediate

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.

Think of it as

Rather than one mutable row that must be locked to keep concurrent access safe, think of every write as adding a new, timestamped version alongside the old one, never erasing it outright. A reader's snapshot is really a rule for "which version of each row is mine to see" — old enough to have existed at the time, not yet superseded from that reader's point of view. Because a reader never needs to touch a row a writer is actively changing (it just looks at an earlier version instead), reading never blocks writing and writing never blocks reading — the core guarantee MVCC exists to provide.

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;

What we're doing: Show a long-running reader and a concurrent writer both proceeding without blocking each other — the direct, observable consequence of MVCC.

mvcc_no_blocking.sqlsql
-- Session A: opens a transaction and takes a snapshot, but does not commit yet
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- reads 1000

-- Session B, concurrently, updates and commits the SAME row --
-- this does NOT wait for Session A, and Session A's earlier SELECT did NOT block it:
UPDATE accounts SET balance = 900 WHERE id = 1;
COMMIT;

-- Session A, still in its original transaction:
SELECT balance FROM accounts WHERE id = 1;  -- still reads 1000 -- its own consistent snapshot
3
Session A's read acquires no lock that would block a writer -- this is MVCC's non-blocking read guarantee in action.
6–7
Session B's write completes immediately, entirely unblocked by Session A's still-open read transaction.
10
Session A's snapshot is unaffected by Session B's commit -- it is reading a different, still-valid row version.
Output
-- Session B: UPDATE 1, COMMIT -- neither waited on Session A
-- Session A: still reads 1000

Why this works: A traditional locking model would have Session B either wait for Session A's read lock to release, or Session A's later read would be forced to see Session B's uncommitted or newly-committed change depending on the locking scheme — MVCC sidesteps both problems by simply keeping the old row version around long enough for Session A's snapshot to keep using it.

Assuming MVCC means "no locking ever happens" in PostgreSQL

Wrong

sql
-- reasoning: "MVCC means reads and writes never block each other,
-- so nothing in PostgreSQL ever needs to wait"
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- a concurrent UPDATE to the SAME row from another session --
-- assumed to also proceed immediately, since "MVCC avoids blocking"

Better

sql
-- MVCC specifically means READERS don't block WRITERS and vice versa --
-- two WRITERS to the same row still conflict and one must wait:
BEGIN;
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- a second concurrent UPDATE to the same row DOES wait, until this
-- transaction commits or rolls back -- MVCC never claimed otherwise

What you see: A developer is surprised when two concurrent UPDATE statements to the same row do block each other, having over-generalized "MVCC avoids blocking" to include writer-vs-writer conflicts, which MVCC never claimed to solve.

Why: MVCC's specific, documented guarantee is reader-vs-writer non-blocking — two genuine writers modifying the same row are still a real conflict that has to be resolved somehow, and PostgreSQL resolves it the ordinary way, by making the second writer wait for the first to finish.

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

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

Remember: 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.

See also: updates create new row versions · transaction snapshots and visibility

Updates Create New Row Versions

coreintermediate

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.

Think of it as

Every row carries two hidden system columns, xmin (the id of the transaction that created this version) and xmax (the id of the transaction that superseded it, if any). An UPDATE is really an INSERT of a new version plus setting xmax on the old one — never an in-place byte-for-byte overwrite. This is the physical mechanism underneath MVCC's snapshot guarantee: a snapshot decides which version is visible by comparing its own view of "which transactions are committed" against each version's xmin/xmax, not by reading some single mutable value.

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

What we're doing: Query xmin/xmax directly before and after an UPDATE to observe the new row version being created, rather than the old one being modified in place.

observe_xmin_xmax.sqlsql
SELECT xmin, xmax, balance FROM accounts WHERE id = 1;
-- xmin = 501, xmax = 0 (unset), balance = 1000

UPDATE accounts SET balance = 900 WHERE id = 1;

SELECT xmin, xmax, balance FROM accounts WHERE id = 1;
-- xmin = 502 (the UPDATE's own transaction id), xmax = 0, balance = 900
-- the row visible now is a DIFFERENT physical row version than before,
-- not the same one with its balance field overwritten
1–2
xmin = 501 identifies which transaction originally created this specific row version.
5–6
After the UPDATE, xmin has changed to 502 — this is observably a NEW row version, not the old one mutated.
Output
 xmin | xmax | balance
------+------+---------
  501 |    0 |    1000

UPDATE 1

 xmin | xmax | balance
------+------+---------
  502 |    0 |     900

Why this works: The change in xmin from 501 to 502 is direct, observable proof that UPDATE created a physically new row version rather than modifying the original bytes in place — the row with xmin=501 still physically exists on disk (now with xmax=502 marking it superseded) until VACUUM eventually reclaims its space.

Assuming a no-op UPDATE (setting a column to its current value) does not create a new row version

Wrong

sql
-- assumption: "balance is already 900, so this UPDATE is basically free"
UPDATE accounts SET balance = 900 WHERE id = 1;  -- balance was already 900
-- expectation: no real work happens since nothing changed

Better

sql
-- an UPDATE always creates a new row version, REGARDLESS of whether
-- the new values differ from the old ones -- guard against genuinely
-- unnecessary writes explicitly if that matters:
UPDATE accounts SET balance = 900 WHERE id = 1 AND balance IS DISTINCT FROM 900;
-- 0 rows affected, and no new row version is created, if the value already matches

What you see: A workload that issues frequent "just in case" UPDATEs — setting a column to a value that is often already correct — accumulates far more table bloat and dead tuples than expected, because every UPDATE call created a new row version even when nothing logically changed.

Why: PostgreSQL has no built-in shortcut that skips creating a new row version just because the new value happens to equal the old one — an UPDATE statement always does the work of writing a new version and marking the old one superseded, so avoiding truly unnecessary writes (via an explicit WHERE ... IS DISTINCT FROM check, or application-level logic) is the only way to actually skip that cost.

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

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

Remember: 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.

See also: multi version concurrency control · dead tuples

Advertisement

Visibility and its consequence

How a snapshot decides what is visible, and the non-blocking guarantee that falls directly out of it.

Transaction Snapshots and Visibility

coreintermediate

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.

Think of it as

A snapshot is not a copy of the data — it is a small piece of bookkeeping (roughly: "which transaction ids counted as committed as of this moment") that gets checked against every row version's xmin/xmax whenever a query runs. A row version is visible to a snapshot if its creating transaction (xmin) was committed as of the snapshot and its superseding transaction (xmax), if any, was NOT committed as of the snapshot. This single rule is what makes MVCC's whole visibility system work without needing to lock anything to compute it.

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;

What we're doing: Contrast Read Committed (fresh snapshot per statement) with Repeatable Read (one snapshot for the whole transaction) using the same concurrent commit in between two reads.

snapshot_scope.sqlsql
-- Session A, Read Committed (default):
BEGIN;
SELECT balance FROM accounts WHERE id = 1;  -- snapshot #1: reads 1000
-- (Session B commits an UPDATE setting balance to 900)
SELECT balance FROM accounts WHERE id = 1;  -- snapshot #2, taken fresh: reads 900
COMMIT;

-- Session A, Repeatable Read:
BEGIN ISOLATION LEVEL REPEATABLE READ;
SELECT balance FROM accounts WHERE id = 1;  -- the ONE snapshot for this whole transaction: reads 1000
-- (Session B commits the same UPDATE)
SELECT balance FROM accounts WHERE id = 1;  -- SAME snapshot reused: still reads 1000
COMMIT;
3
Read Committed: this statement gets its own, brand-new snapshot.
5
A different, later snapshot — taken fresh for this statement — legitimately sees the intervening commit.
10
Repeatable Read: this single snapshot is now fixed for the rest of the transaction.
12
Reusing the SAME snapshot object means this read cannot see the intervening commit, by construction — not by re-checking anything, simply because it consults the same fixed rule as before.
Output
-- Read Committed: 1000, then 900
-- Repeatable Read: 1000, then 1000

Why this works: The difference between the two isolation levels is entirely explained by how many times a snapshot is taken, not by any different locking behavior — Read Committed's "freshness" and Repeatable Read's "stability" are both direct, mechanical consequences of snapshot scope.

Assuming a snapshot is a full copy of the affected data, rather than a lightweight visibility rule

Wrong

sql
-- reasoning: "Repeatable Read must copy the whole database at BEGIN time,
-- so it should be slow to start a transaction on a huge database"
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- (assumed to be doing a large amount of upfront work here)

Better

sql
-- a snapshot is small bookkeeping (roughly: which xids were committed
-- at this moment), NOT a copy of any row data -- taking one is cheap
-- regardless of table size
BEGIN ISOLATION LEVEL REPEATABLE READ;
-- effectively instant, independent of how much data exists

What you see: A developer avoids using Repeatable Read out of a mistaken belief that it is expensive to start, based on imagining it must copy or lock the whole dataset up front.

Why: A snapshot only needs to record which transactions counted as committed at a point in time — checking any individual row version against it is a cheap comparison against that row's existing xmin/xmax, not a data copy operation, which is exactly why MVCC can provide this guarantee without meaningful upfront cost proportional to table size.

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

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)

Remember: 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.

See also: updates create new row versions · transaction isolation levels

Why Readers Generally Do Not Block Writers, and Vice Versa

standardintermediate

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.

Think of it as

Blocking exists to prevent two operations from stepping on the same mutable state at the same time — but under MVCC, a reader and a writer are never actually looking at the same mutable state, because the writer creates a brand-new row version rather than mutating the one the reader might be using. There is nothing to protect the reader from, and nothing the reader could corrupt for the writer, so no lock is needed between them at all. This "generally" in the roadmap's own phrasing matters: DDL (like ALTER TABLE) and a few exceptional operations still need genuine locks that can block a reader — MVCC's non-blocking guarantee is specifically about ordinary row-level reads and writes.

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

What we're doing: Time a slow report query and a concurrent write together, confirming the write does not wait on the report.

report_does_not_block_write.sqlsql
-- Session A:
SELECT count(*), pg_sleep(5) FROM accounts;  -- simulates a slow report -- 5 seconds

-- Session B, started immediately after Session A, concurrently:
\timing
UPDATE accounts SET balance = balance - 100 WHERE id = 1;
-- completes in milliseconds -- does NOT wait for Session A's 5-second query
2
Session A's read is deliberately slow, to make any blocking obvious if it happened.
6
Session B's write is not affected by Session A's still-running read at all -- it completes at normal speed.
Output
UPDATE 1
Time: 3.417 ms   -- not ~5000ms, confirming no blocking occurred

Why this works: Session A's report query never needs Session B's row to stay unchanged — it is working from its own snapshot's view, entirely independent of whatever Session B does concurrently — so there is no reason for either to wait on the other, and the millisecond timing confirms it directly rather than assuming it.

Assuming a long-running SELECT is "safe" from blocking DDL the same way it is safe from blocking ordinary writes

Wrong

sql
-- a long-running report query is in flight:
SELECT count(*) FROM accounts;  -- still running, 30 seconds in

-- meanwhile, a deploy script runs a schema migration:
ALTER TABLE accounts ADD COLUMN notes text;
-- BLOCKS -- waits for the report query to finish, because ALTER TABLE
-- needs a lock the report's SELECT is (indirectly) holding

Better

sql
-- schedule schema migrations during low-traffic windows, or use
-- techniques (like a very short initial lock + backfill separately)
-- that avoid holding up long-running reads -- MVCC's non-blocking
-- guarantee does not extend to DDL

What you see: A schema migration that "should be instant" (ADD COLUMN with no default, for instance) unexpectedly hangs in production, and pg_stat_activity shows it waiting on a lock held by an ordinary, unrelated SELECT query.

Why: MVCC's non-blocking guarantee is specifically about ordinary row-level reads and writes — DDL statements like ALTER TABLE need a stronger table-level lock that DOES conflict with an in-progress SELECT, which is exactly the "generally" in this concept's own roadmap phrasing: the rule has a real, documented exception for schema changes.

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

Remember: 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.

See also: multi version concurrency control · select for update and row locking

Advertisement

The cleanup side

What a dead tuple actually is, and how VACUUM reclaims it.

Dead Tuples

coreintermediate

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.

Think of it as

Not every superseded row version is immediately dead — a row version with xmax set is only truly dead once no currently-open transaction's snapshot could still legitimately need to see it. Until then it is merely "old," still potentially visible to some long-running transaction that started before the UPDATE or DELETE happened. This is exactly why a long-running transaction (covered earlier, in Transactions — Core Competency) can prevent cleanup: it holds back the point past which a superseded row version can be safely called dead.

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

What we're doing: Generate dead tuples with repeated UPDATEs and observe the dead tuple count grow via pg_stat_user_tables, then watch VACUUM reclaim them.

observe_dead_tuples.sqlsql
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
-- n_dead_tup = 0

UPDATE accounts SET balance = balance + 1 WHERE id = 1;  -- repeat several times
UPDATE accounts SET balance = balance + 1 WHERE id = 1;
UPDATE accounts SET balance = balance + 1 WHERE id = 1;

SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
-- n_dead_tup = 3 -- each UPDATE left its previous version as a dead tuple

VACUUM accounts;
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';
-- n_dead_tup = 0 -- VACUUM reclaimed them
4–6
Each UPDATE creates a new row version and leaves the previous one dead — three UPDATEs, three dead tuples.
10–12
VACUUM finds and reclaims exactly the dead tuples that accumulated — this is the mechanical link between UPDATE activity and the need to vacuum.
Output
 n_dead_tup 
------------
          0

(3 updates)

 n_dead_tup 
------------
          3

VACUUM
 n_dead_tup 
------------
          0

Why this works: This is the direct, observable link between ordinary write activity and the need for regular vacuuming — every UPDATE (and DELETE) leaves behind a dead tuple once it is no longer needed by any open snapshot, and n_dead_tup is PostgreSQL's own running count of exactly how much cleanup work has accumulated.

Assuming a row that was UPDATEd or DELETEd is immediately gone and its space immediately reusable

Wrong

sql
DELETE FROM accounts WHERE id = 1;
-- assumption: "the space that row used is now free for something else"
-- reality: it is a dead tuple, still occupying disk space, until VACUUM runs

Better

sql
DELETE FROM accounts WHERE id = 1;
-- the space is reclaimed by the NEXT VACUUM (autovacuum, typically,
-- running on its own schedule) -- not instantly, and not by the DELETE itself
VACUUM accounts;  -- or run it explicitly to see the effect sooner

What you see: A table that has had a large number of rows deleted does not shrink in size the way expected, and disk usage stays high until the next autovacuum run (or an explicit VACUUM) actually reclaims the dead tuples.

Why: DELETE (and the superseded half of an UPDATE) only marks a row version as no-longer-current by setting its xmax — actually reclaiming that space is a separate, later step that VACUUM performs, precisely because the row version might still be legitimately visible to some other, still-open transaction's snapshot at the moment the DELETE itself runs.

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)

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

Remember: 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.

See also: how vacuum removes obsolete row versions · transaction duration and its costs

How VACUUM Removes Obsolete Row Versions

standardintermediate

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.

Think of it as

Standard VACUUM is closer to defragmenting free space within a file than to shrinking the file itself — it finds gaps left by dead tuples and makes them available for the NEXT write to reuse, keeping the table's on-disk size roughly steady-state rather than growing forever under heavy UPDATE/DELETE activity. This is a deliberate design trade-off: reclaiming and reusing space in place is much cheaper and far less disruptive than rewriting the whole table, which is exactly what VACUUM FULL does instead, at the cost of an exclusive lock and real downtime for a large table.

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

What we're doing: Show that a standard VACUUM, after deleting many rows, reclaims space for reuse without shrinking the table's reported disk size — while VACUUM FULL does shrink it.

vacuum_vs_vacuum_full_size.sqlsql
SELECT pg_size_pretty(pg_relation_size('accounts'));  -- e.g. 100 MB

DELETE FROM accounts WHERE created_at < now() - interval '1 year';  -- removes half the rows

VACUUM accounts;
SELECT pg_size_pretty(pg_relation_size('accounts'));  -- still ~100 MB --
                                                        -- space was freed for REUSE, not shrunk

VACUUM FULL accounts;
SELECT pg_size_pretty(pg_relation_size('accounts'));  -- now ~50 MB --
                                                        -- the table was actually rewritten smaller
5–6
Standard VACUUM ran and reclaimed the dead tuples, but the file size on disk did not shrink — the space is now free for future INSERTs to reuse instead.
10–11
Only VACUUM FULL actually rewrites the table into a smaller file and returns the difference to the operating system.
Output
 pg_size_pretty 
----------------
 100 MB

(after VACUUM)
 100 MB

(after VACUUM FULL)
 50 MB

Why this works: This is exactly the trade-off standard VACUUM is designed around: it is cheap, non-blocking, and keeps the table healthy for ongoing writes by making dead space reusable, but a reader checking disk usage alone would wrongly conclude "nothing happened" — the real effect is on how much of that 100 MB is now free space available for the table's own future growth, not on the file size itself.

Running VACUUM FULL routinely, expecting it to behave like standard VACUUM but "better"

Wrong

sql
-- scheduled nightly job:
VACUUM FULL accounts;  -- "full" sounds more thorough, so use it every time
-- takes an ACCESS EXCLUSIVE lock, blocking ALL reads and writes to the
-- table for the entire duration -- a serious outage on a large, busy table

Better

sql
-- routine maintenance: rely on autovacuum (or explicit VACUUM) for the
-- common case; reserve VACUUM FULL for rare situations where the table
-- has genuinely bloated far beyond what routine vacuuming can keep up with
VACUUM accounts;  -- non-blocking, run this routinely instead

What you see: A nightly maintenance job that runs VACUUM FULL causes a full application outage every time it runs, because the ACCESS EXCLUSIVE lock blocks every other query against the table for the operation's full duration.

Why: "FULL" describes how thoroughly it reclaims space, not that it is simply a stronger, always-better version of ordinary VACUUM — the ACCESS EXCLUSIVE lock and the need to write an entirely new copy of the table are real, serious operational costs that make VACUUM FULL appropriate only for occasional, deliberate use, never as a routine replacement for standard VACUUM (or autovacuum, which handles the routine case automatically).

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

Remember: 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.

See also: dead tuples · vacuum vacuum full and analyze

Advertisement