When should I switch to a covering index with INCLUDE to get an index-only scan?
Question
I have a frequently-run lookup in PostgreSQL: I filter on `WHERE customer_id = $1` and read the `status` and `total` columns. I already have a B-tree index on `customer_id` and it does find the rows. The problem is that in the EXPLAIN output I still see a heap access: after the index locates the row, it goes to the table (heap) to fetch `status` and `total`. When should I move to a covering index with `INCLUDE` to fetch these two extra columns without hitting the heap—and will I actually get an index-only scan if I do?
Answer
Short answer: switch when the query is genuinely hot (high frequency, low-latency expectation) and the heap access is a measured cost.
Short answer
But one caveat: a covering index alone does not guarantee an index-only scan — that depends on the table’s visibility-map state.
Why
-
INCLUDE stores columns in leaf pages only.
INCLUDE (status, total)does not add these columns to the B-tree’s sort key; they carry no comparison cost but sit there to satisfy the query without a heap visit. That’s exactly your goal. -
Index-only scans depend on the visibility map. Postgres skips the heap only if the page is marked “all-visible”. On a write-heavy table that map goes stale between vacuums; then, even with a covering index, EXPLAIN shows
Heap Fetches: N. So sometimes the real lever isn’t the index — it’s tightening autovacuum.CREATE INDEX idx_orders_customer ON orders (customer_id) INCLUDE (status, total); EXPLAIN (ANALYZE, BUFFERS) SELECT status, total FROM orders WHERE customer_id = $1; -- Want: "Index Only Scan ... Heap Fetches: 0" -
It’s a write/space tradeoff. Wider leaf pages = bigger index, more WAL, slower writes, more to keep in cache. That cost is only worth it for genuinely hot reads; don’t slap a covering index on every query.
-
INCLUDE columns are for projection only. Don’t forget this: columns added via
INCLUDEcan’t be used in theWHEREfilter orORDER BY; they’re there purely to satisfy theSELECToutput. If you’ll also filter on that column, it has to go into the key (possibly a composite index); if you only need to return it, INCLUDE is the right place.
What to do
-
Prefer INCLUDE over adding to the key. Putting
status,totalinto the index key can bloat internal nodes and imposes an ordering you don’t need. INCLUDE keeps the tree lean and only widens the leaf — which is all you need to cover the query. -
Verify with EXPLAIN (ANALYZE, BUFFERS). The job isn’t done until you see “Index Only Scan” and “Heap Fetches: 0”. If heap fetches stay high, the fix may be
VACUUM(or lowering autovacuum thresholds), not more index. Track whether the index is actually used viapg_stat_user_indexes; a covering index nobody touches is just write overhead.
Bottom line: personally I’d first confirm with EXPLAIN (ANALYZE, BUFFERS) that the heap access really is the dominant cost, then rebuild the customer_id index with INCLUDE (status, total), and tighten autovacuum for that table until I see Heap Fetches: 0. If the index bloats over time, tidy it up with REINDEX CONCURRENTLY. In short: if these two columns really are the read-only payload of a hot query, a covering index pays off; if not, adding an index without measuring usually just slows down the write path.
Comments
Sign in with your GitHub account to join the discussion. Comments are stored in GitHub Discussions.