Indexes10 min read

PostgreSQL Index-Only Scans and the Visibility Map

An index-only scan is only index-only when the visibility map says the heap page is all-visible. How to read Heap Fetches in EXPLAIN, build covering indexes with INCLUDE, and keep vacuum honest.

An index-only scan is the closest thing PostgreSQL has to a free lunch: the query is answered entirely from the index, the heap is never touched, and buffer traffic drops to a fraction of what an index scan would need. It is also one of the most quietly conditional optimizations in the database. I have seen teams build a beautiful covering index, confirm the plan says Index Only Scan, and then wonder why the query is still reading thousands of heap blocks. The missing piece is almost always the visibility map — a tiny structure with two bits per heap page that decides whether "index-only" is actually index-only.

This is the failure mode that makes people distrust the planner when the planner was never the problem. Three things cover it: the mechanics, the EXPLAIN output that tells the truth, and the operational habit — vacuum — that keeps the whole thing working.

Why the heap is consulted at all

PostgreSQL's MVCC stores visibility in the heap tuple header: xmin and xmax, the transactions that created and deleted the version. An index entry carries no visibility information. So on a plain index scan, the executor finds the index entry, follows its pointer into the heap, and checks the tuple header against its snapshot to decide whether the row is visible to this query. That heap fetch is the expensive part: one extra page touch per row, random in the worst case.

The visibility map is the escape hatch. For every heap page it keeps a bit that says "every tuple on this page is visible to every transaction" — the all-visible flag. When an index-only scan needs a row whose index entry points to an all-visible page, the executor trusts the bit and skips the heap fetch entirely. No bit, no skip. It does not matter that your table is logically read-only at 3am; if vacuum has never marked the page, the heap gets fetched.

Reading Heap Fetches in EXPLAIN

EXPLAIN ANALYZE tells you exactly how often the escape hatch failed. Look for the Heap Fetches counter on the Index Only Scan node:

EXPLAIN (ANALYZE, BUFFERS)
SELECT order_id, status, total
FROM orders
WHERE customer_id = 12345;

If the plan reports "Heap Fetches: 0", the visibility map did its job. If Heap Fetches is close to the row count, you have an index-only scan in name only — every row still cost a heap page touch, and the shared-hit total in the BUFFERS line climbs accordingly. That total lumps heap and index blocks together, so Heap Fetches, not the node type and not the buffer count, is the ground truth about whether your covering index is paying off.

The two questions that follow are mechanical. How much of the table is currently marked all-visible? And why is it not more?

Checking visibility map coverage directly

The pg_visibility extension exposes the map page by page. One quick query gives you the coverage ratio:

CREATE EXTENSION IF NOT EXISTS pg_visibility;

SELECT count(*) AS pages,
       count(*) FILTER (WHERE all_visible) AS all_visible_pages
FROM pg_visibility('orders');

Divide all_visible_pages by the pages count from the same query and you have the percentage of the table an index-only scan can trust; reaching for pg_class.relpages instead compares against an estimate that can lag badly right after a bulk load, which is exactly when you are looking. On a healthy, well-vacuumed, mostly-static table this runs above 95 percent. On a table that never gets vacuumed — because autovacuum is tuned off for it, or because its insert rate has not yet crossed the insert threshold (autovacuum_vacuum_insert_threshold plus a fifth of the table, by default) — coverage can sit near zero for weeks while every dashboard says things are fine.

Covering indexes and INCLUDE

None of this matters unless the index actually covers the query, meaning every column the query touches lives in the index. Since PostgreSQL 11, the INCLUDE clause is the right tool for the non-key columns:

CREATE INDEX orders_customer_inc
  ON orders (customer_id)
  INCLUDE (order_id, status, total);

Included columns are stored on leaf pages but are not part of the sort key, so they do not widen the btree levels that searches traverse, and they cannot serve as index search keys or bounds. That does not make them return-only decoration: an index-only scan can still evaluate a WHERE clause on an included column straight from the index tuple, without a heap fetch. But the key columns drive selectivity and traversal — the included columns exist to cover the query's column list. Resist the urge to INCLUDE half the table. Every included column makes the index bigger, slows its maintenance, and eats cache. Cover your three hottest query shapes, not every shape you can imagine.

Vacuum is the thing that sets the bit

Here is the operational heart of it: only vacuum sets all-visible bits, and any insert, update, or delete on a page clears them. Autovacuum eventually gets around to every table with dead tuples, but a bulk load can leave millions of fresh pages unmarked, and an insert-mostly table with few dead tuples may not attract autovacuum's attention quickly at all. The result is the classic confusion: the day after a big import, your flagship report query is suddenly doing heap fetches for every row, and the plan "did not change."

The fix is unglamorous — run VACUUM (optionally with ANALYZE) on the table after large loads, and treat visibility-map coverage as a first-class health signal on tables that serve index-only scans. If autovacuum struggles to keep up with churn, tuning its aggressiveness on that table pays off twice: less bloat and better VM coverage. The interplay with freeze work is covered in our vacuum guide, and the query-level symptoms pair well with the EXPLAIN ANALYZE field guide.

The honest limits

Index-only scans are not a universal target. For queries returning most of the table, the sequential scan wins regardless of VM state. For high-churn tables, the bits get cleared almost as fast as vacuum sets them, and Heap Fetches will hover high no matter what you do — that is physics, not misconfiguration, and the covering index may still be worth it for the ordering it provides. And remember that the visibility map is a hint structure with a real guarantee behind it: PostgreSQL skips heap fetches only because the all-visible bit is a promise maintained with WAL-level care, never an approximation.

One more practical note: after REINDEX or adding a fresh covering index, nothing about the VM changes — coverage is a heap property. If Heap Fetches stays high on a static table, the index is not the problem; vacuum the heap and watch the counter drop on the next run.

How MonPG keeps this visible

The gap between "plan says index-only" and "execution fetches heap" slips past code review because nothing errors; latency just drifts. I watch it with MonPG, which monitors PostgreSQL in production today: query plans, buffer profiles, and per-table vacuum and autovacuum health on the same timeline. When a bulk load drops visibility-map coverage and Heap Fetches climbs, the vacuum history that explains it sits next to the counter that moved, instead of arriving as a context-free latency alert. The slow query monitoring surface is where that investigation starts, and the broader PostgreSQL monitoring overview shows how it fits together.