MVCC Key Points
Multi-Version Concurrency Control, or MVCC, is the mechanism PostgreSQL uses to let many transactions read and write the same data at the same time without stepping on each other.
Instead of locking a row for every reader the way older database designs did, PostgreSQL keeps multiple versions of a row around and lets each transaction see the version that was correct as of its own snapshot.
This page builds the mental model behind that system before you reach the more applied pages in this section on isolation levels, row-level locks, and long transactions.
Understanding MVCC changes how you reason about concurrency bugs, because most of the surprising behavior you will hit in production traces back to what a transaction's snapshot could and could not see.
Summary
- Core Idea: PostgreSQL never overwrites a row in place, it writes a new version and leaves the old one until nothing needs it anymore.
- Why It Matters: This design lets reads and writes proceed concurrently without readers blocking writers or writers blocking readers.
- Key Concepts: snapshot, tuple version, xmin/xmax, visibility, dead tuple, vacuum.
- When to Use: This model is always active, but it matters most when reasoning about long-running transactions, bloat, and isolation-level choices.
- Limitations / Trade-offs: Old row versions accumulate as bloat until vacuum reclaims them, and a long-running transaction can hold that reclamation back indefinitely.
- Related Topics: transactions basics, isolation levels, row-level locks, long transactions, serialization anomalies.
Foundations
Every row in a PostgreSQL table carries two hidden system columns, xmin and xmax, that record which transaction created the row version and which transaction, if any, deleted or superseded it.
When you run an UPDATE, PostgreSQL does not modify the existing row in place, it inserts a brand-new row version with a fresh xmin and marks the old version's xmax with the updating transaction's ID.
A DELETE works the same way: the row is not physically removed immediately, it is only marked with an xmax so other transactions know it should no longer be considered current.
This means a single logical row can have many physical tuple versions stacked up in the table at once, and PostgreSQL's job at read time is to figure out which version is the right one for the transaction asking.
That decision is made using a snapshot, a record of which transactions were already committed, in-progress, or not yet started at the moment the snapshot was taken.
A row version is visible to a transaction only if its creating transaction had committed before the snapshot was taken and its deleting transaction, if any, had not.
Think of it like a shared document with full version history: every edit creates a new revision instead of erasing the old one, and each reader is handed the specific revision that matches the moment they opened the document.
You can observe this directly by looking at the hidden columns on a row.
SELECT xmin, xmax, ctid FROM app.accounts WHERE id = 1;The ctid shown alongside them is the row version's physical location, which changes every time an update creates a new version in a new spot.
Mechanics & Interactions
The practical payoff of this design is PostgreSQL's core concurrency guarantee: readers never block writers, and writers never block readers.
A long-running SELECT simply keeps using its own snapshot and never needs to wait for a concurrent UPDATE to finish, because that update is creating a new version rather than modifying the one the reader already has.
Writers can still block other writers, since two transactions trying to update the same row must serialize through a row-level lock, but that is a much narrower case than blocking every reader too.
This is where isolation levels enter the picture, and it is worth being precise about what they actually control.
An isolation level does not change how locking works at the storage layer, it changes how a transaction's snapshot behaves and which concurrency anomalies it tolerates.
Read Committed, PostgreSQL's default, takes a fresh snapshot for every statement within a transaction, so each statement sees the latest committed data at the moment it runs.
Repeatable Read takes a single snapshot for the entire transaction, so every statement inside it sees the same consistent view even if other transactions commit changes in between.
Serializable goes further still, detecting and rejecting the subset of concurrent execution patterns that could not have happened if transactions had run one at a time, even when Repeatable Read's snapshot alone would not catch the conflict.
The cost of MVCC's write-a-new-version approach is that old versions do not disappear on their own.
A dead tuple, a row version no transaction can see anymore, sits in the table taking up space until vacuum reclaims it for reuse.
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname = 'accounts';Autovacuum runs this cleanup automatically in the background, but a transaction that stays open for a long time can prevent vacuum from reclaiming versions that are technically dead, because that old transaction's snapshot might still need them.
Advanced Considerations & Applications
The single most consequential MVCC failure mode in production is the idle-in-transaction session, a connection that opened a transaction, took a snapshot, and then sat there without committing or rolling back.
That open snapshot holds back autovacuum's cleanup horizon for every table in the database, not just the ones the transaction touched, which means dead tuples accumulate everywhere until that one connection finally closes.
idle_in_transaction_session_timeout exists specifically to bound this risk by forcibly closing connections that overstay their welcome.
Bloat, the accumulated space consumed by dead tuples that vacuum has not yet reclaimed, has real performance consequences beyond disk usage, since scans and index lookups have to skip past dead versions to find live ones.
MVCC also interacts directly with the schema and locking decisions covered elsewhere in this documentation: a soft delete pattern using a deleted_at column produces its own form of bloat unless paired with periodic archival, and a SELECT ... FOR UPDATE explicitly takes a row-level lock on top of MVCC's snapshot mechanics to prevent a specific class of write-write race a snapshot alone cannot resolve.
Choosing an isolation level is ultimately a trade-off between anomaly tolerance and application complexity, and different levels are genuinely better fits for different workloads.
| Isolation Level | Strength | Weakness | Best Fit |
|---|---|---|---|
| Read Committed | Low overhead, intuitive per-statement freshness, PostgreSQL's default | Vulnerable to non-repeatable reads within one transaction | Most everyday OLTP transactions with short, simple logic |
| Repeatable Read | Consistent view across the whole transaction, immune to non-repeatable reads | Can hit serialization failures on write-write conflicts that must be retried | Multi-statement reports or logic that must see one coherent snapshot |
| Serializable | Strongest guarantee, behaves as if transactions ran one at a time | Highest chance of retry-worthy conflicts, some throughput cost | Financial or invariant-critical logic where any anomaly is unacceptable |
Long-lived analytical queries and OLTP writes competing for the same tables is a common tension MVCC helps but does not fully eliminate, since a long report under Repeatable Read still holds its snapshot open for the report's full duration, with the same vacuum-blocking consequence as an idle-in-transaction session.
Monitoring pg_stat_activity for transactions in active or idle in transaction state with an old xact_start is the standard operational habit for catching this class of problem before it becomes a bloat incident.
Common Misconceptions
- "An UPDATE modifies the row in place." PostgreSQL writes a new tuple version and marks the old one dead, it never rewrites bytes inside an existing row version.
- "A SELECT can block a concurrent UPDATE, or vice versa." MVCC is specifically designed so readers and writers never block each other, only writers competing for the same row block each other.
- "Higher isolation levels mean more locking." Isolation level changes snapshot behavior and anomaly detection, it does not fundamentally change how row-level locks are acquired.
- "Deleted rows are gone immediately." A DELETE only marks a row version as no longer current, the physical space is not reclaimed until vacuum runs and no transaction still needs it.
- "Vacuum is optional cleanup I can defer indefinitely." Deferring vacuum lets dead tuples accumulate into bloat that degrades scan and index performance, and in extreme cases risks transaction ID wraparound.
FAQs
What does MVCC actually stand for and what problem does it solve?
Multi-Version Concurrency Control lets PostgreSQL give every transaction a consistent view of the data without making readers and writers wait on each other, by keeping multiple versions of each row instead of one shared, lockable copy.
What are xmin and xmax?
xminis the ID of the transaction that created a given row version.xmaxis the ID of the transaction that deleted or superseded it, if any.- PostgreSQL uses both, compared against a transaction's snapshot, to decide whether a version is visible.
Why doesn't a long-running SELECT block other transactions from updating the same rows?
The SELECT is reading from its own snapshot of already-committed versions, and an UPDATE creates a new version rather than altering the one the SELECT already has, so the two never contend for the same physical data.
What is a snapshot in MVCC terms?
A snapshot is a transaction's private record of which other transactions counted as committed at the moment it started reading, and it is what determines which row versions that transaction is allowed to see.
Does isolation level change how locks work?
No, isolation level changes what a transaction's snapshot sees and which concurrent execution patterns get rejected as anomalies, while row-level locking behavior for conflicting writers stays largely the same across levels.
Why does my table keep growing even though I'm deleting old rows?
- Deleted and updated-away rows become dead tuples, not freed space, at the moment of the write.
- Autovacuum reclaims that space in the background, but it needs to run and complete for it to happen.
- A long-running transaction elsewhere in the database can delay that reclamation even on tables it never touches.
What's dangerous about an idle-in-transaction session?
An open transaction's snapshot holds back the point up to which autovacuum can safely reclaim dead tuples across the entire database, so one forgotten open transaction can cause bloat everywhere, not just in the tables it queried.
What's the difference between Read Committed and Repeatable Read in practice?
Read Committed takes a new snapshot for every statement, so two SELECTs in the same transaction can see different data if something commits in between, while Repeatable Read takes one snapshot for the whole transaction so every statement sees the same consistent view.
When should I use Serializable instead of Repeatable Read?
Serializable is worth the extra retry overhead when the logic enforces an invariant that a snapshot alone cannot protect, such as ensuring a sum across rows never exceeds a limit under concurrent writes.
Can a row version be visible to one transaction but not another at the same instant?
Yes, that is the entire point of MVCC: each transaction's snapshot independently determines visibility, so two transactions running at the same moment can legitimately see different versions of the same logical row.
How do I check if MVCC bloat is becoming a problem?
pg_stat_user_tables exposes n_dead_tup per table, and a steadily climbing number relative to live rows, combined with autovacuum not keeping pace, is the standard early signal worth investigating.
Does MVCC apply the same way on a read replica?
A replica applies the same version-and-visibility model to the write-ahead log it streams from the primary, but a long-running query on a replica can conflict with incoming replayed changes in ways that are handled differently from primary-side MVCC conflicts.
Related
- Transactions Basics - BEGIN, COMMIT, ROLLBACK, and transaction boundaries
- MVCC Visibility - hands-on examples reading xmin, xmax, and dead tuple stats
- Isolation Levels - Read Committed, Repeatable Read, and Serializable in depth
- Row-Level Locks - explicit locking on top of MVCC's snapshot model
- Long Transactions - the operational risk of holding a snapshot open too long
- Serialization Anomalies - the concurrency bugs isolation levels are built to prevent
Stack versions: This page was written for PostgreSQL 18.4 (stable major 18, maintenance line 17 also supported).