Index-Only Scan Eligibility Rules #
A covering index exists, the query selects only indexed columns, and the plan still shows Index Scan with heap access. Or it shows Index Only Scan with Heap Fetches: 4102110 — an index-only scan that touches the heap for nearly every row. Both are common, and each has a different cause among the three conditions an index-only scan must satisfy.
Designing covering indexes is covered in covering index design. This page is the checklist for why one is not used.
The Index Condition #
An index-only scan is possible when all three hold:
- Every column the query needs from the table comes from the index. That means columns in the
SELECTlist,WHERE,ORDER BY,GROUP BY,HAVING, join conditions and returned expressions — for the relation being scanned. Key columns andINCLUDEcolumns both count. ASELECT *, an unindexed column in a filter, or a column used only by a later join disqualifies it. - The access method can return the indexed values. B-tree can. GiST can for some operator classes, SP-GiST for some, and GIN never can, because its entries are tokens rather than whole values. An index on an expression can satisfy a query that uses exactly that expression.
- The rows’ pages are marked all-visible. The executor checks the visibility map before trusting index-only data. Pages modified since the last vacuum are not marked, so their rows require a heap fetch to check visibility — reported as
Heap Fetches.
Condition 3 is why index-only scans degrade on write-heavy tables and recover after VACUUM, the mechanism described in visibility map and index-only scan regressions.
The planner estimates the fraction of all-visible pages from pg_class.relallvisible and costs the expected heap fetches, so a table whose visibility map is stale may not even get an index-only plan.
Annotated EXPLAIN Evidence #
CREATE INDEX CONCURRENTLY orders_customer_placed_idx
ON orders (customer_id, placed_at) INCLUDE (total);
EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, placed_at, total
FROM orders WHERE customer_id = 4821 ORDER BY placed_at DESC LIMIT 20;
Eligible and healthy:
Limit (actual time=0.03..0.04 rows=20 loops=1)
-> Index Only Scan Backward using orders_customer_placed_idx on orders
(actual time=0.03..0.03 rows=20 loops=1)
Index Cond: (customer_id = 4821)
Heap Fetches: 0
Buffers: shared hit=5
Execution Time: 0.06 ms
A column outside the index:
SELECT customer_id, placed_at, total, status FROM orders WHERE customer_id = 4821 …;
-> Index Scan Backward using orders_customer_placed_idx on orders (actual rows=20 loops=1)
Index Cond: (customer_id = 4821)
Buffers: shared hit=24
-- status is not in the index: every row needs its heap tuple
Stale visibility map:
-> Index Only Scan Backward using orders_customer_placed_idx on orders
(actual time=0.05..118.4 rows=20 loops=1)
Index Cond: (customer_id = 4821)
Heap Fetches: 18402
Buffers: shared hit=18410 read=204
-- Heap Fetches ≫ rows returned: recently written pages are not marked all-visible
Step-by-Step Resolution #
-
List every column the query touches for that table — including
WHERE,ORDER BYand join conditions, not just the select list. -
Compare with the index definition.
SELECT indexdef FROM pg_indexes WHERE indexname = 'orders_customer_placed_idx'; -
Add missing columns. Columns used in conditions or ordering must be key columns; columns only returned can be
INCLUDEcolumns, which keep the key small — the trade-off in INCLUDE columns vs composite keys. -
Check the access method. For GIN, an index-only scan is impossible; a B-tree on the same column, or a different query shape, is the alternative.
-
Check visibility.
SELECT relallvisible, relpages, last_vacuum, last_autovacuum FROM pg_class c JOIN pg_stat_user_tables t ON t.relid = c.oid WHERE relname = 'orders';A low
relallvisible ÷ relpagesratio means most pages are not marked.VACUUM orders;updates it. -
Keep the ratio high on hot tables by tuning autovacuum for them — see per-table autovacuum settings for hot tables.
-
Re-check
Heap Fetchesafter vacuum; it should be close to zero for a stable table.
Before and After #
-- BEFORE: SELECT adds status, which is not in the index
Index Scan Backward using orders_customer_placed_idx Buffers: shared hit=24 Execution Time: 0.14 ms
-- AFTER: INCLUDE (total, status)
Index Only Scan Backward using orders_customer_placed_idx Heap Fetches: 0 Buffers: shared hit=5 Execution Time: 0.06 ms
When an index-only scan is not worth chasing #
Every column added to an index makes it larger, slower to maintain and more likely to be rewritten on updates. For a query returning twenty rows, the difference between five buffers and twenty-four is invisible to users. Index-only scans pay off when the row count is large — aggregates over millions of rows, range scans feeding joins, and counts — because there the heap fetches dominate. A useful rule: chase index-only scans for queries whose returned or scanned row counts are in the thousands or more, and accept plain index scans for point lookups.
There is also a subtlety with count(*). PostgreSQL can answer SELECT count(*) FROM t with an index-only scan of the smallest index, but it must still read every index entry and check visibility, so the query is proportional to the table size. It is faster than a sequential scan, not instant, and its speed depends entirely on how much of the visibility map is set.
Index-only scans on aggregates and joins #
The clearest wins come from queries that never return the rows they read. SELECT count(*) FROM orders WHERE customer_id = $1, SELECT max(placed_at) …, and semi-joins used only to test existence all read many index entries and need no table columns at all — provided every condition is in the index. Those are worth designing covering indexes for even when the result is a single number, because the alternative reads one heap page per matching row. The same applies to the inner side of a nested loop: an index-only probe avoids a heap page per outer row, which at high loop counts is the difference between a fast plan and an I/O-bound one.
Common Pitfalls #
Forgetting filter and join columns. They must be in the index too. Diagnostic signal: plain index scan despite a covering select list. Fix: add them, as keys if they are used in conditions.
SELECT * in ORMs. It references every column. Diagnostic signal: no index-only scans anywhere in an application. Fix: narrow projections for hot queries.
Expecting index-only scans from GIN. Not supported. Diagnostic signal: Bitmap Heap Scan always present for JSONB or full-text queries. Fix: different index type or accept heap access.
Vacuum starvation on hot tables. Heap fetches climb between vacuums. Diagnostic signal: query time rising through the day, dropping after autovacuum. Fix: per-table autovacuum tuning.
Frequently Asked Questions #
Why is PostgreSQL not using an index-only scan with my covering index? #
Some column the query needs is not in the index, the access method cannot return values, or the table’s visibility map is stale enough that the planner expects heap fetches for most rows.
What does Heap Fetches mean in an Index Only Scan? #
It counts rows whose heap page was not marked all-visible, so the executor had to read the heap tuple to check visibility. A high count means the table needs vacuuming more often.
Can GIN indexes support index-only scans? #
No. GIN stores keys extracted from values rather than the values themselves, so it cannot return the original column data required for an index-only scan.
Related #
- Covering Index Design — parent guide: designing indexes that answer queries
- Building Effective Covering Indexes — sibling: choosing key and INCLUDE columns
- Visibility Map and Index-Only Scan Regressions — keeping heap fetches at zero