Lesson 0001 · Storage & 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.
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.
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."
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."
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.
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).
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.
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.
| Question | InnoDB (you know this) | PostgreSQL (this course) |
|---|---|---|
| What is a table? | A B+tree clustered by primary key | An unordered heap of pages |
| What does UPDATE do? | Edits the row in place | Writes 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 log | VACUUM / 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 |
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?
Dead tuples pile up in the heap. Which process reclaims that space?
How many clustered indexes does a normal Postgres table have?
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?
A table only ever UPDATEd (never grown) balloons in size. The cause is:
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.
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.