Lesson 0001 · Storage & MVCC

The heap & MVCC

Postgres never updates a row in place — it writes a new version and leaves the old one behind. Get this one fact and VACUUM, bloat, and "every index is secondary" all derive themselves.

~9 minRetrieval quiz + InnoDB contrastCheat sheet: here

You already know InnoDB cold: the table is a B+tree clustered by primary key, an UPDATE edits the row in place, and the old image is tucked into the undo log so other transactions can still read it. Postgres makes the opposite choice at every step — and this first lesson is the keystone the whole course hangs off. Get it and a surprising amount derives itself: why a table can bloat when you only ever run UPDATE, why VACUUM and autovacuum exist at all, why Postgres has no clustered index, and why an index lookup often still has to touch the table.

The one idea

In Postgres a table is a heap: an unordered pile of pages holding row versions (called tuples). Concurrency is handled by MVCC — the database keeps multiple versions of a row so that each statement sees a consistent snapshot. PostgreSQL Docs: "data consistency is maintained by using a multiversion model… each SQL statement sees a snapshot of data… as it was some time ago, regardless of the current state of the underlying data."

The consequence that changes everything: an UPDATE or DELETE does not overwrite or remove the old row. It writes a new version and marks the old one as expired. PostgreSQL Docs: "In PostgreSQL, an UPDATE or DELETE of a row does not immediately remove the old version of the row… the row version must not be deleted while it is still potentially visible to other transactions."

AN UPDATE IN POSTGRES — UPDATE users SET name='Ann2' WHERE id=42 heap page (before) heap page (after the UPDATE) ┌────────────────────────┐ ┌────────────────────────────────┐ │ (42) name=Ann live │ ──▶ │ (42) name=Ann DEAD (expired) │ ← old version stays │ (17) name=Bo live │ │ (17) name=Bo live │ until VACUUM └────────────────────────┘ │ (42) name=Ann2 live (new) │ ← new version appended └────────────────────────────────┘ InnoDB would edit row 42 in place and push the old image to the UNDO log. Postgres keeps BOTH versions right in the heap and cleans up later.
The key insight

InnoDB stores the current row and hides old versions in a side channel (undo). Postgres stores every version inline in the heap and needs a background janitor to remove the expired ones. That janitor is VACUUM. The old versions it removes are dead tuples — space that must be reclaimed "to avoid unbounded growth of disk space requirements." PostgreSQL Docs: "The space it occupies must then be reclaimed for reuse by new rows… This is done by running VACUUM."

Why readers never block writers

Because the old version physically survives, a transaction that started earlier can keep reading it while a newer transaction writes a new version beside it. Neither waits on the other. PostgreSQL Docs: "reading never blocks writing and writing never blocks reading." You met this exact guarantee in MySQL lesson 0005 — a plain SELECT is a lock-free snapshot. Same idea; Postgres just keeps the versions in the heap instead of reconstructing them from undo.

The first consequence: bloat

Now a beginner surprise becomes obvious instead of mysterious. Run a million UPDATEs against the same thousand rows and the table can grow to many times its logical size — every update left a dead tuple behind, and until VACUUM reclaims them the pages stay allocated. This is bloat, and it is a direct, derivable consequence of "never update in place." InnoDB's in-place model simply doesn't have this failure mode (it pays elsewhere, in undo-log pressure and purge).

The second consequence: no clustered index — and every index is "secondary"

InnoDB must keep the table physically ordered by primary key, because the table is that B+tree. A heap has no such order. Postgres will physically reorder a table for you with CLUSTER — but it is a one-time operation and is not maintained as new rows arrive. PostgreSQL Docs: "Clustering is a one-time operation: when the table is subsequently updated, the changes are not clustered… no attempt is made to store new or updated rows according to their index order."

So there is no "the row lives in the primary-key tree" shortcut. Every Postgres index is a separate structure off to the side that points into the heap. PostgreSQL Docs: "All indexes in PostgreSQL are secondary indexes, meaning that each index is stored separately from the table's main data area (which is called the table's heap in PostgreSQL terminology)." Contrast this sharply with what you learned in MySQL lesson 0001: InnoDB has exactly one clustered index (the table) and the rest are secondary. Postgres has zero clustered indexes — they are all secondary.

The payoff — why an index lookup still touches the table

A tuple's visibility (is this version live for my snapshot?) is recorded in the heap, not in the index. So finding a key in an index isn't enough to know the row is visible — Postgres normally must visit the heap to check. PostgreSQL Docs: "Visibility information is not stored in index entries, only in heap entries; so at first glance it would seem that every row retrieval would require a heap access anyway." The escape hatch is the visibility map, a bit per heap page marking it "all-visible"; an index-only scan can skip the heap only when that bit is set. We give this its own lesson (0003) — for now, just hold the shape: index → heap, unless the page is known all-visible.

Postgres vs InnoDB — the keystone contrast

QuestionInnoDB (you know this)PostgreSQL (this course)
What is a table?A B+tree clustered by primary keyAn unordered heap of pages
What does UPDATE do?Edits the row in placeWrites a new version, expires the old
Where do old versions live?In the undo log (side channel)Inline in the heap as dead tuples
Who cleans up old versions?Purge threads drain the undo logVACUUM / autovacuum reclaims tuples
Clustered index?Exactly one (the table itself)None — every index is secondary
Is row visibility in the index?N/A (secondary points back by PK)No — it's in the heap → visibility map

Check yourself

Answer from memory — the effortful recall is what builds retention. Feedback is immediate.

In Postgres, what does an UPDATE physically do to the old row version?

Postgres appends a new version and marks the old one expired; it stays in the heap as a dead tuple until VACUUM reclaims it. Moving the old image to undo is the InnoDB behaviour.

Dead tuples pile up in the heap. Which process reclaims that space?

VACUUM (usually via autovacuum) removes row versions no longer visible to any transaction, reclaiming the space for reuse and preventing unbounded growth.

How many clustered indexes does a normal Postgres table have?

All Postgres indexes are secondary, stored separately from the heap. CLUSTER reorders once but isn't maintained, so there is no InnoDB-style always-clustered index.

Why must an index scan usually still visit the heap page?

Visibility info is kept in heap entries, not index entries — so the index alone can't prove a row is visible to your snapshot. The visibility map is what lets an index-only scan skip the heap.

A table only ever UPDATEd (never grown) balloons in size. The cause is:

Each UPDATE leaves the prior version as a dead tuple in the heap; until VACUUM reclaims them the pages stay allocated — that's bloat. Undo-log growth is the InnoDB analogue, not Postgres.
Optional — when you have an instance

No Postgres handy yet, so this is a lab to run later (Docker: docker run --rm -e POSTGRES_PASSWORD=x -p 5432:5432 postgres). It makes the whole lesson visible in three commands:

CREATE TABLE t (id int PRIMARY KEY, name text);
INSERT INTO t VALUES (42,'Ann');
SELECT ctid, id, name FROM t;          -- ctid = (page, slot): the tuple's physical address
UPDATE t SET name='Ann2' WHERE id=42;
SELECT ctid, id, name FROM t;          -- ctid CHANGED — it's a NEW tuple, not an edit
SELECT n_dead_tup FROM pg_stat_user_tables WHERE relname='t';  -- 1 dead tuple
VACUUM t;                              -- reclaim it; n_dead_tup drops back to 0

The ctid changing on a plain UPDATE is the keystone made concrete: the row didn't move, a new version was born. Bring the output to your teacher and we'll read it together.

Primary source — read this next

PostgreSQL Docs — 13.1 Introduction to MVCC (two short paragraphs), then 24.1 Routine Vacuuming for why the old versions linger and how they're reclaimed. For the physical picture underneath — heap pages, tuple headers, the ctid — the clearest ground truth is "The Internals of PostgreSQL" (Suzuki), ch. 5–6.