Skip to content

Index Only Scan still hits the heap. What is it checking?

The database has a plan · Part 5 of 5 · 6 min read

Read the worked explanation ↓

The interview question. You add a covering B-tree index for a merchant order summary. EXPLAIN reports an Index Only Scan, but EXPLAIN ANALYZE shows many heap fetches. The scan is efficient against last year's orders and disappointing against today's actively updated orders. Explain why the index contains every required value but still needs the heap. Decide whether adding more index columns, vacuum tuning, or a different data path is appropriate.

Assume PostgreSQL's heap storage and MVCC behavior, and that the index genuinely supports every column required by the query. This is not a missing-index-key question or a planner-cache issue. The missing fact is about which row versions the query is allowed to see.

Think first

A payment-summary endpoint has a covering B-tree index, and PostgreSQL chooses Index Only Scan. Against older payment rows, Heap Fetches is nearly zero. Against recently updated rows, Heap Fetches is high. Explain the MVCC rule, what an all-visible page means, what vacuum changes, and why adding an updated INCLUDE column may make writes more expensive.

Take a few minutes to form an approach, then compare it with the worked explanation.

Reveal the worked explanation

The index knows the values but not the whole visibility story

Consider this query and one possible index:

SELECT created_at, total_cents
FROM orders
WHERE merchant_id = :merchant
ORDER BY created_at DESC
LIMIT 100;
CREATE INDEX orders_recent_cover
ON orders (merchant_id, created_at DESC)
INCLUDE (total_cents);

The index stores enough information to locate matching entries, produce the required order, and return the selected fields. In PostgreSQL terminology, the INCLUDE column is payload. It is not another search key and does not affect the ordering. On paper, no heap access seems necessary.

But PostgreSQL permits multiple versions of a logical row to exist during concurrent transactions. A query must return versions visible to its own snapshot. A B-tree index entry does not, by itself, carry all the information needed to decide visibility for every transaction. If the database returned every matching index entry without checking, it could expose a row version that should not be visible or return an obsolete one. PostgreSQL's index-only scan documentation is explicit about this boundary.

An index-only scan uses the visibility map to skip heap pages known all-visible and fetches heap tuples when the visibility bit is unset
An index-only scan uses the visibility map to skip heap pages known all-visible and fetches heap tuples when the visibility bit is unset

The optimization is not “never visit the table.” It is “avoid table visits when the database can prove they are unnecessary.”

One bit per heap page changes the read path

PostgreSQL maintains a small visibility map for each heap relation. One of its bits records whether a particular heap page is all-visible, meaning every tuple on that page is known visible to all relevant snapshots. The index-only executor finds an index entry, checks the visibility map bit for the referenced heap page, and can return the value directly when that bit is set. If the bit is not set, it visits the heap and checks the tuple's visibility.

The map is conservative. A missing all-visible bit does not prove that the row is invisible. It means the shortcut cannot be trusted. Data changes clear the all-visible status of affected pages, while vacuum can set it again when the page qualifies. This is documented in PostgreSQL's visibility map internals.

That explains the age difference in our report. Last year's finalized orders are rarely modified. Many heap pages may be all-visible, so an index-only scan can avoid most heap access. Today's orders undergo payment confirmation, fulfillment updates, and refunds. The affected pages repeatedly lose the shortcut, and the executor visits their heap tuples despite holding all requested data in the index.

The plan name is an opportunity, not an I/O receipt

A useful measurement is:

EXPLAIN (ANALYZE, BUFFERS)
SELECT created_at, total_cents
FROM orders
WHERE merchant_id = 42
ORDER BY created_at DESC
LIMIT 100;

Look for the Index Only Scan node and its Heap Fetches counter. Compare hot and old partitions or time ranges, along with buffer activity and query frequency. A scan that makes 100 index visits and needs 90 heap fetches is not getting the same benefit as one that needs 2. Those values are illustrative, and page caching may still make heap fetches relatively cheap.

Also separate two causes. If the plan is an ordinary Index Scan, perhaps the index does not contain everything the query needs or the planner judged it cheaper. If it is Index Only Scan with many heap fetches, the visibility-map path is the immediate reason for those table visits. Adding total_cents twice will not change the visibility bits.

Vacuum activity can help restore all-visible pages, but it cannot promise that actively changing pages always qualify. Long-lived snapshots and ongoing modifications can delay cleanup or keep changing the visibility state. Running VACUUM FULL in production to solve a hot-read problem is not a routine tuning step. PostgreSQL distinguishes ordinary vacuum from the more disruptive full table rewrite in its maintenance guidance.

The extra index can also make updates more expensive

Every index duplicates some data and introduces maintenance work. PostgreSQL has an optimization called HOT, or heap-only tuple updates, that can avoid new index entries when updated columns are not referenced by the table's non-summarizing indexes and enough space is available on the old heap page. PostgreSQL 18 exempts summarizing indexes such as BRIN from that indexed-column restriction; the covering B-tree discussed here is not exempt. Changing a column stored as an index key or INCLUDE payload can remove that opportunity. PostgreSQL documents the prerequisites in its HOT storage notes.

This creates a real design tradeoff. Adding total_cents to the covering index might accelerate reads on stable orders, but if total_cents changes frequently, the index can also raise update cost and reduce HOT opportunities. The measure of success is the combined read and write workload, not an impressive Index Only Scan label.

For a frequently updated order dashboard, I would first inspect actual heap fetches, update patterns, vacuum progress, and the usefulness of the index to the endpoint. If the freshest data needs consistently low latency, a maintained projection or a narrower write model may be worth evaluating. That solution has a separate freshness and correction contract, so it should not be presented as free.

The most important lesson is that a database index participates in a concurrency model. It is not merely a sorted file of values. The engine must decide which committed history each query is entitled to observe, and the index alone does not always provide that proof.

Go deeper

Visibility versus HOT, without conflating them

The visibility map tracks a property of heap pages used to avoid visibility checks. HOT is a different optimization affecting update and index-maintenance work. Frequent updates can both clear all-visible bits and create row versions, but it is wrong to say that running HOT automatically guarantees efficient index-only scans. HOT can avoid some new index entries while a modified heap page still lacks the all-visible shortcut.

INCLUDE values are copied into leaf tuples, not used as search keys. Wide payload columns may make index tuples too large or increase B-tree page pressure, and unique indexes still enforce uniqueness over the key columns rather than the included values. The planner can prefer a different path even when an index-only scan is theoretically possible.

The right monitoring approach ties the workload to metrics: Heap Fetches in the plan, visibility statistics for the relation, the rate of updates and HOT updates, index growth, and vacuum progress. Avoid treating one plan from a quiet test database as proof for a production table receiving constant writes.

Check your reasoning

  • State that the index must store requested values but does not itself prove snapshot visibility.
  • Explain visibility-map all-visible bits and heap fallback when a bit is unset.
  • Use EXPLAIN ANALYZE Heap Fetches to distinguish Index Only Scan from no heap I/O.
  • Describe vacuum and frequent modification without promising immediate all-visible status.
  • Explain covering-index write cost and why changing indexed columns can remove HOT opportunities.

Common mistake: Assuming that the name Index Only Scan guarantees no table reads, or that VACUUM FULL is routine tuning.

A PostgreSQL Index Only Scan shows many Heap Fetches even though all selected columns are included in the index. What is most likely missing?

Read the answer explanation

A set all-visible bit for some referenced heap pages, so visibility must be checked in the heap. — MVCC visibility is not stored in B-tree tuples. A page without an all-visible map bit requires a heap visibility check.

Apply it elsewhere

A SaaS audit-log table is append-mostly while a live-session table changes every second. Both have covering indexes. Which is more likely to benefit from index-only scans, and what would you measure?

Compare your approach

Contrast stable all-visible heap pages with frequently modified pages. Examine Heap Fetches, visibility map state, update rates, and VACUUM behavior before increasing index width.

One more constraint

You INCLUDE a frequently updated billing field to accelerate a query. What happens to HOT-update eligibility and total write cost?

Quick revision

An index-only scan is possible when the index type supports it and every required value can come from the index. But PostgreSQL still has to enforce MVCC visibility. It checks an all-visible bit in the heap page's visibility map. If that bit is unset, it fetches the heap tuple even though the value is already in the index. Stable pages benefit more than constantly updated pages. I would inspect EXPLAIN (ANALYZE, BUFFERS) and Heap Fetches, check vacuum and update patterns, and weigh covering-index read savings against index maintenance and lost HOT-update opportunities. More included columns cannot repair missing visibility evidence.

References