skip to content

A plan reports an index-only scan, yet the query still performs a large number of table (heap) fetches. Why can an index entry alone be insufficient to return a row in an MVCC database, and what makes those extra fetches go away?

level: seniorimportance: should knowfreq 38%

answer

  1. Index has keys, not transaction visibility
  2. Visibility map bit per page: all-visible
  3. VACUUM sets the bit, any write clears it
  4. Heap Fetches counter in the plan node
  5. Long/idle transactions block cleanup

basics

~20 s

Index entries carry no transaction visibility information, so the engine cannot always tell whether an entry points at a row version this transaction may see. When it cannot, it fetches the row to check. Keeping table maintenance current — so pages are marked all-visible — removes most of those fetches.

solid answer

~1 min

Under MVCC, an update or delete does not overwrite a row: it creates a new version and leaves the old one until cleanup. Version metadata — which transaction created and which deleted a version — lives **with the row**, not in the index. An index entry therefore says "a row with this key exists somewhere", not "this row is visible to you". So a pure index-only scan is only safe when the engine can establish visibility without reading the row. PostgreSQL does this with a per-page **visibility map**: if a table page is marked all-visible, every entry pointing into it can be returned straight from the index; if not, that entry triggers a heap fetch. The plan still says "Index Only Scan", and reports the number of heap fetches it had to do. So a high heap-fetch count means many table pages are not marked all-visible — typically a heavily updated table, or one where autovacuum is falling behind or is blocked by a long-running transaction or an idle-in-transaction session holding an old snapshot. The fix is maintenance, not index design: get vacuum running (or tune it more aggressively for that table), and remove whatever is pinning old snapshots.

code

text · 9 lines
text
Index Only Scan using idx_orders_cover on orders
  Index Cond: (tenant_id = 42)
  Heap Fetches: 98211        <- almost every row still read from the table
  rows=99000

-- healthy version of the same node
Index Only Scan using idx_orders_cover on orders
  Heap Fetches: 12
  rows=99000

go deeper

for a junior

Know the fact: indexes do not record which transactions can see a row, so the engine sometimes has to read the row to check.

for a middle

Explain the visibility-map mechanism — vacuum sets an all-visible bit per page, writes clear it — and connect it to the Heap Fetches number in the plan.

for a senior

Diagnose end to end: read the counter, correlate with write rate and vacuum activity, hunt the snapshot holder, and decide whether the covering index is worth keeping on a write-hot table.

for a principal

Turn it into an operational contract — per-table autovacuum policy, monitoring for old snapshots and replication-slot lag, and a rule that covering indexes are only promised on tables whose churn the maintenance budget can absorb.

## The MVCC premise In a multi-version concurrency control engine, writers do not block readers because a modification produces a **new version** of the row rather than overwriting the old one. Each version carries metadata about which transaction made it visible and which transaction (if any) removed it. A reader compares that metadata against its own snapshot to decide whether it may see that version. Old versions stay around until a background cleanup process determines that no snapshot can still need them. ## Why the index cannot decide visibility Indexes store keys and row pointers. They generally do **not** store per-version transaction metadata — duplicating it would mean updating every index on the table whenever a version's status changed, which would destroy write performance. The consequence: an index entry may point to a version that is dead (deleted or superseded) or not yet visible to your snapshot. Returning data straight from an index entry without a check could resurrect deleted rows or expose uncommitted ones. So an "index-only" scan needs some cheaper proof of visibility, or it must fall back to reading the row. ## The PostgreSQL mechanism: the visibility map PostgreSQL keeps a compact bitmap with a couple of bits per table page. One bit means **all-visible**: every tuple on that page is visible to every current and future snapshot. `VACUUM` sets it; any write to the page clears it. During an index-only scan, for each matching entry the executor checks the map bit of the page the entry points into: - bit set → return the values straight from the index entry; - bit clear → fetch the heap tuple and check visibility properly. So the plan node is genuinely "Index Only Scan", and it reports `Heap Fetches: N`. `N` near zero is the win you were aiming for; `N` near the row count means you are paying the ordinary two-step cost plus a wider index. ## Why the map bits are clear - **Recent writes.** A table with continuous updates constantly clears bits. A brand-new or just-bulk-loaded table also has clear bits until vacuumed. - **Vacuum not keeping up.** Autovacuum thresholds scaled to a huge table can leave a hot table under-vacuumed for a long time. - **Something holding an old snapshot.** A long-running transaction, a session sitting idle-in-transaction, an abandoned replication slot, or a prepared transaction left hanging prevents cleanup from advancing, so pages cannot be marked all-visible no matter how often vacuum runs. This is the classic case where the same query was fast last week and is slow now with no schema change. ## The fixes, in order 1. **Find and kill the snapshot holder** — long transactions, idle-in-transaction sessions, stale replication slots. Nothing else works until cleanup can advance. 2. **Make vacuum keep up on that table** — per-table autovacuum settings so a hot table is vacuumed on its own churn rather than a global threshold; a manual vacuum after a bulk load or migration. 3. **Reduce churn on the covered columns** — fewer updates to the table means fewer cleared bits; leaving free space on pages so updates can be handled without touching indexes helps too. 4. **Reconsider the index** — if the table is inherently write-hot, a wide covering index may simply not pay, and a narrower index plus row fetches may be the honest choice. ## Other engines The general principle — visibility information lives with the row, not the index — is not PostgreSQL-specific, but the mechanics are. Engines that store rows in a clustered index keyed by the primary key have a different shape: a secondary-index lookup lands in the clustered index, and versioning is reconstructed from undo information, so the equivalent question becomes when a secondary index alone suffices and when the engine must consult the clustered index or undo log. Present the principle first and label the mechanism you name as engine-specific. ## How to answer Start with the principle: index entries carry keys, not visibility. Then name a concrete mechanism (visibility map, set by vacuum, cleared by writes) and the observable symptom (`Heap Fetches` high in an Index Only Scan node). Then diagnose upstream: what stops pages from being all-visible — write rate versus vacuum rate, and old snapshots. Finish with the honest tradeoff that some tables should not carry a wide covering index at all.

  • How do you find what is preventing cleanup from marking pages all-visible?
    Look for the oldest transaction still holding a snapshot: long-running or idle-in-transaction backends, unconsumed replication slots, and prepared transactions that were never committed or rolled back. Any one of them pins the cleanup horizon so vacuum cannot mark pages all-visible even while it runs. Terminating or fixing the holder, then vacuuming, is what actually restores the index-only plan.
  • Would rebuilding or widening the index reduce the heap fetches?
    No — the fetches are caused by visibility uncertainty, not by missing columns, so a wider index adds write cost without removing a single fetch. Rebuilding may help unrelated problems like bloat, but the heap-fetch counter is driven by how many target pages are marked all-visible. The lever is table maintenance and snapshot hygiene.

A catalogue card tells you a book exists on shelf 12, but not whether it has been withdrawn since the card was printed. If the shelf has a 'nothing withdrawn here' sticker you can trust the card; otherwise you walk over and check.

saying these in an interview costs you the question

  • Assuming an index-only scan never touches the table
  • Blaming the index definition for a high heap-fetch count and adding more columns
  • Thinking VACUUM only reclaims space and has nothing to do with plan performance
  • Ignoring idle-in-transaction sessions and replication slots as the cause
  • Claiming the index stores transaction visibility information

context