Lesson 0003 · Indexes & Access Paths
Every index is secondary, so an index scan lands you at the heap. This lesson is about the one thing that lets Postgres skip that heap trip — the visibility map — and why the VACUUM from last lesson is what powers it.
Closed book — pull these from memory before the new material. They're the exact hinges this lesson swings on.
After plain VACUUM on a bloated table, the file on disk:
A HOT update requires both: no indexed column changes, and —
Recall the keystone consequence from 0001: Postgres has no clustered index, so
every index is secondary and stores a pointer — the tuple's
ctid, its (page, slot) address — into the
heap. That single fact sets up both the cost this lesson explains and the optimization
that dodges it.
To answer SELECT name FROM t WHERE email = 'a@x.com' through an index on
email, Postgres does:
a@x.com → it
yields a ctid.ctid — the
heap fetch — to read name.You met this shape in MySQL lesson 0001 as the "bookmark lookup." The difference: in InnoDB only secondary indexes pay it — a primary-key read lands the whole row in one descent. In Postgres there is no clustered index, so every index scan is potentially a two-step, including the primary key.
Here's the natural question: if the index already holds every column the query needs, why touch the heap at all? Because — recall 0001 — a row's visibility (is this version live for my snapshot?) is recorded in the heap tuple, not in the index entry. — 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." So even a fully-covering index can't be trusted on its own: the entry might point to a dead tuple, or one your transaction shouldn't see yet.
Postgres keeps a compact visibility map — one bit per heap page — marking pages where every tuple is visible to all transactions. — PostgreSQL Docs: "PostgreSQL tracks, for each page in a table's heap, whether all rows stored in that page are old enough to be visible to all current and future transactions. This information is stored in a bit in the table's visibility map." An index-only scan checks that bit first: if the page is all-visible, the heap fetch is skipped and the answer comes from the index alone. If not, it must visit the heap after all — no better than an ordinary index scan. — PostgreSQL Docs: "An index-only scan… checks the visibility map bit for the corresponding heap page. If it's set, the row is known visible… If it's not set, the heap entry must be visited…"
Who sets those all-visible bits? VACUUM.
— PostgreSQL Docs: "Vacuum maintains a visibility map for each table to keep track of which pages contain only tuples that are known to be visible to all active transactions" — and one stated reason to vacuum is "To update the visibility map, which speeds up index-only scans."
So the two lessons lock together: an index-only scan is only fast on a
well-vacuumed table. Heavy updates dirty pages, clearing their all-visible
bits, so under-vacuumed hot tables quietly lose the optimization until autovacuum
catches up.
— PostgreSQL Docs: "it will be a win only if a significant fraction of the table's heap pages have their all-visible map bits set."
InnoDB gave you covering indexes for free: ask only for columns the secondary index
holds (plus the PK it carries), and you get Using index. Postgres has the
same idea but you build it explicitly, and you can bolt on payload
columns that aren't part of the search key with INCLUDE:
CREATE INDEX tab_x_y ON tab (x) INCLUDE (y); -- x is searchable; y is payload
SELECT y FROM tab WHERE x = 'key'; -- can be an index-only scan
A Postgres covering index still isn't enough by itself — it only skips the heap when the page's all-visible bit is set. So a covering index on a churny table can still pay heap fetches. The docs are blunt: "there is little point in including payload columns in an index unless the table changes slowly enough that an index-only scan is likely to not need to access the heap." The optimization has a maintenance dependency (VACUUM) that InnoDB's model never exposed you to.
| Question | InnoDB | PostgreSQL |
|---|---|---|
| Read by primary key | One descent — row is in the leaf | Two-step — index entry → heap fetch |
| Non-covering secondary read | Bookmark lookup into clustered index | Heap fetch at the tuple's ctid |
| Covering index | Using index — answered from index | Index-only scan — only if page all-visible |
| Payload/covering syntax | Add columns to the secondary index | INCLUDE (…) non-key columns |
| What powers "skip the table" | Index already has it | The visibility map, maintained by VACUUM |
From memory. Two items reach back to earlier lessons on purpose.
Why can't a covering index in Postgres always skip the heap?
An index-only scan skips the heap fetch when the page is:
Which process sets the all-visible bits that index-only scans rely on? (recall 0002)
The pointer a Postgres index entry stores into the heap is the: (recall 0001)
A covering index on a heavily-updated table often still pays heap fetches because:
A lab to run later. It shows an index-only scan appearing only after a VACUUM sets the visibility bits:
CREATE TABLE t (id int PRIMARY KEY, x int, y int);
INSERT INTO t SELECT g, g % 100, g FROM generate_series(1,100000) g;
CREATE INDEX tab_x_y ON t (x) INCLUDE (y);
EXPLAIN (ANALYZE) SELECT y FROM t WHERE x = 42; -- likely "Index Only Scan" but Heap Fetches: > 0
VACUUM t; -- sets all-visible bits
EXPLAIN (ANALYZE) SELECT y FROM t WHERE x = 42; -- now Heap Fetches: 0
Watch the Heap Fetches: line drop to 0 after the vacuum — that's the
visibility map doing its job. Then UPDATE t SET y = y + 1; and re-run to
watch heap fetches come back until the next vacuum. Bring the plans to your teacher.
PostgreSQL Docs — 11.9 Index-Only Scans and Covering Indexes
(short and superb — the two requirements, INCLUDE, and the "only a win if
well-vacuumed" caveat all live here). For the physical picture of index entries pointing
at the heap, Suzuki's "Internals of PostgreSQL," ch. 1
and the Visibility Map storage page.