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.
Search across all documentation pages
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.
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.
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.
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.
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.
xmin is the ID of the transaction that created a given row version.xmax is the ID of the transaction that deleted or superseded it, if any.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.
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.
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.
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.
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.
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.
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.
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.
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.
Stack versions: This page was written for PostgreSQL 18.4 (stable major 18, maintenance line 17 also supported).
Reviewed by Chris St. John·Last updated Jul 15, 2026