Lesson 0003 · Indexes & Access Paths

Secondary indexes & the visibility map

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.

~10 minWarm-up recall + quiz + InnoDB contrastCheat sheet: here

Warm-up: recall lessons 0001–0002 first

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:

Plain VACUUM marks dead space reusable within the table but doesn't hand it back to the OS. Only VACUUM FULL rewrites and shrinks (under an exclusive lock).

A HOT update requires both: no indexed column changes, and —

Both conditions: no indexed column changed AND the new version fits on the same page. Then it's heap-only and touches no index. Lower fillfactor helps condition two.

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.

An index scan is two steps

To answer SELECT name FROM t WHERE email = 'a@x.com' through an index on email, Postgres does:

  1. Walk the index to find the entry for a@x.com → it yields a ctid.
  2. Fetch the heap tuple at that 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.

Why not just answer from the index?

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.

The visibility map: the escape hatch

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…"

The cross-link — this is why you learned VACUUM first

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."

Covering indexes — Postgres's version

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

PostgreSQL Docs: "an index defined as CREATE INDEX tab_x_y ON tab(x) INCLUDE (y); could handle these queries as index-only scans, because y can be obtained from the index without visiting the heap."

The catch InnoDB hides

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.

Postgres vs InnoDB — reading through an index

QuestionInnoDBPostgreSQL
Read by primary keyOne descent — row is in the leafTwo-step — index entry → heap fetch
Non-covering secondary readBookmark lookup into clustered indexHeap fetch at the tuple's ctid
Covering indexUsing index — answered from indexIndex-only scan — only if page all-visible
Payload/covering syntaxAdd columns to the secondary indexINCLUDE (…) non-key columns
What powers "skip the table"Index already has itThe visibility map, maintained by VACUUM

Check yourself

From memory. Two items reach back to earlier lessons on purpose.

Why can't a covering index in Postgres always skip the heap?

Visibility lives in the heap tuple, not the index entry. So unless the page is marked all-visible, Postgres must visit the heap to confirm the row is visible to your snapshot.

An index-only scan skips the heap fetch when the page is:

The index-only scan checks the visibility-map bit; if the page is all-visible, the heap trip is skipped. Caching and clustering don't determine visibility.

Which process sets the all-visible bits that index-only scans rely on? (recall 0002)

VACUUM maintains the visibility map — a stated reason to vacuum is to speed up index-only scans. So the optimization depends on the table being well-vacuumed.

The pointer a Postgres index entry stores into the heap is the: (recall 0001)

Postgres indexes point at a physical tuple by ctid (page, slot). Storing the PK value as the pointer is InnoDB's secondary-index design, not Postgres's.

A covering index on a heavily-updated table often still pays heap fetches because:

Every update dirties a page and clears its all-visible bit until the next vacuum, so a churny table has few all-visible pages — and index-only scans degrade to ordinary index scans.
Optional — when you have an instance

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.

Primary source — read this next

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.