MVCC & VACUUM — How Postgres Actually Manages Storage

PostgreSQL

Chapter 9 · MVCC & VACUUM — How Postgres Actually Manages Storage

This is this course's own central architectural chapter — not a syntax difference, but a genuinely different physical storage strategy underneath everything covered so far.

MVCC — Multi-Version Concurrency Control

MVCC exists so that readers never block writers and writers never block readers — each transaction sees a consistent snapshot of the database as of when it started, even while other transactions concurrently modify data. To be fair from the outset: this isn't unique to Postgres. MySQL's InnoDB engine has real, solid MVCC too — a common misconception worth correcting directly rather than implying otherwise.

What genuinely differs is how each engine physically implements it. Every Postgres row (tuple) carries hidden system columns xmin and xmaxxmin records the transaction ID that created this row version, xmax records the transaction ID that deleted or superseded it. An UPDATE in Postgres never modifies a row in place — it creates an entirely new tuple with a new xmin, and sets xmax on the old tuple to mark it superseded. That old version isn't removed immediately; it becomes a dead tuple.

InnoDB takes a different physical approach entirely: it keeps one current row in the main table data, plus a separate undo log storing the information needed to reconstruct older versions for transactions that still need to see them. Postgres duplicates the row itself on every update; InnoDB keeps one row plus reconstructable history.

Dead Tuples & Why VACUUM Exists

This is the direct, structural consequence of Postgres's own "new tuple per update" strategy: every UPDATE and DELETE leaves behind dead tuples — no longer visible to any current or future transaction, but still physically occupying disk space until cleaned up.

VACUUM's job is to scan a table, identify dead tuples no longer needed by any currently-running transaction, and mark that space as reusable for future inserts and updates. An important nuance: ordinary VACUUM does not shrink the file on disk — that's VACUUM FULL, a much heavier, table-locking operation. Ordinary VACUUM just marks space as internally reusable, so the table can absorb new data without allocating new disk space, without ever actually returning space to the operating system.

If VACUUM falls behind — running less often than the table's own update/delete rate demands — dead tuples accumulate faster than they're reclaimed, and the table's real on-disk size can grow well beyond what its live data actually requires. This is table bloat, a genuine, real operational concern with no real equivalent in InnoDB's own architecture, precisely because InnoDB never creates new physical tuples on update the same way.

autovacuum

Postgres runs an autovacuum background process by default, automatically triggering VACUUM (and ANALYZE, which refreshes query-planner statistics) once a table crosses a configurable dead-tuple threshold — the practical, day-to-day answer to dead tuples, running automatically rather than needing manual scheduling. Tuning knobs like autovacuum_vacuum_scale_factor and autovacuum_vacuum_threshold control exactly when it triggers per table; a high-churn table often needs more aggressive tuning than the defaults provide.

Contrasted With MySQL's InnoDB

PostgreSQLMySQL (InnoDB)
MVCC?Yes, in both — real, working implementations in each
Physical strategyNew tuple per UPDATE, old tuple marked deadOne current row + a separate undo log for older versions
Cleanup mechanismVACUUM / autovacuumA purge thread reclaiming undo log entries
Distinctive operational riskTable bloat if VACUUM falls behindUndo/history-list growth under long-running transactions

Both strategies are real, working trade-offs, not a case of one engine "having MVCC" and the other not. Postgres's strategy makes table bloat a genuine, distinctive operational concern; InnoDB avoids that specific problem, but has its own version of it — a long-running transaction can prevent old undo-log entries from being purged, causing InnoDB's own undo tablespace to grow instead. Different physical strategies, each with its own honest cost.

The single most common real trigger of severe table bloat
A single old, still-open transaction — even an idle one left open by an application bug, or a long-running analytical query — prevents VACUUM from cleaning up any dead tuple newer than that transaction's own snapshot, since that transaction might still legitimately need to see those older versions. Autovacuum can be running perfectly normally and this can still happen — the bottleneck isn't a lack of vacuuming, it's a transaction that's been left open far longer than intended, silently blocking cleanup the entire time. This is the single most common real-world cause of severe table bloat in production Postgres systems.
Closing the loop back to postgres1-1
postgres1-1's own process-per-connection material and c3-3's pthreads material both set up this chapter's real payoff: MVCC is the actual mechanism that lets many concurrent processes read a consistent view of the data without blocking each other — the storage-level answer to the concurrency model question this course opened with.

Hands-On Exercises

Exercise 1

Explain how Postgres physically implements MVCC using xmin/xmax and new tuple versions on UPDATE, and explain specifically why this creates dead tuples as a direct, structural consequence.

📄 View solution
Exercise 2

Explain what VACUUM does — and importantly, what ordinary VACUUM does NOT do, versus VACUUM FULL — and explain "table bloat" as a consequence of VACUUM falling behind.

📄 View solution
Exercise 3

Using this chapter's own warn-box, explain how a single long-running or idle-open transaction can cause severe table bloat even when autovacuum is running normally, and why VACUUM can't simply ignore that old transaction's own requirements.

📄 View solution

Chapter 9 Quick Reference

  • Both Postgres and InnoDB have real MVCC — the difference is physical strategy, not whether MVCC exists at all
  • Postgres — new tuple per UPDATE (xmin/xmax), old tuple becomes a dead tuple · InnoDB — one current row + a separate undo log
  • VACUUM marks dead tuple space reusable — it does NOT shrink the file (that's VACUUM FULL, table-locking)
  • Table bloat — dead tuples accumulating faster than VACUUM reclaims them; no real InnoDB equivalent
  • autovacuum — the automatic, default answer, tunable per table via scale_factor/threshold settings
  • A single long-running/idle-open transaction is the most common real cause of severe bloat, even with autovacuum running normally
  • MVCC is the actual mechanism enabling postgres1-1's own process-per-connection concurrency model to work without heavy locking
  • Next chapter: Extensions & the Postgres Ecosystem — CREATE EXTENSION, PostGIS