HAMZA KAREEM / CONCEPTS hamzawi.dev

Lesson 942 min read

Index-Only Scans & the Visibility Map

Debug-Along

Databasesdatabases / query performance

The symptom

Dashboard query, 400ms p95. There IS an index. EXPLAIN says it's used. Nobody can see the problem.

SELECT user_id, status
FROM orders
WHERE tenant_id = 42
  AND created_at > now() - interval '30 days';

CREATE INDEX ON orders (tenant_id, created_at);

Step 1 — read the plan, not the index list

Index Scan using orders_tenant_created_idx  (rows=48213)
  Buffers: shared hit=112 read=46901

112 index pages... and ~47k other page reads. Where from?

The index stores only (tenant_id, created_at, ctid). Your SELECT also wants user_id and status. Those live in the table heap. So for every one of the 48k matching rows Postgres walks back to a heap page — in physical-order-random sequence. That's the 47k reads. The index found the rows fast; fetching the columns is what's slow.

Step 2 — the concept: make the index cover the query

CREATE INDEX CONCURRENTLY orders_tenant_created_cov
  ON orders (tenant_id, created_at) INCLUDE (user_id, status);

Now every column the query needs is inside the index → Postgres can answer without touching the heap at all. Plan node changes to:

Index Only Scan ... Heap Fetches: 0

Same query, 400ms → ~15ms.

Step 3 — the part that makes you intermediate

Run it on a hot table and you'll see this instead:

Index Only Scan ...  Heap Fetches: 43887

Covering index, still hitting the heap. Why?

An index entry does not know whether its row is visible to your transaction — MVCC visibility lives in the heap tuple. So Postgres checks the visibility map: 1 bit per heap page saying "every tuple on this page is visible to everyone." All-visible page → skip the heap. Otherwise → fetch it anyway.

VACUUM is what sets those bits. On a table with steady writes, recently-touched pages are not all-visible, so "index-only" quietly degrades to a regular index scan.

VACUUM (ANALYZE) orders;   -- then re-run EXPLAIN

If Heap Fetches stays high on a write-heavy table, tune autovacuum for it:

ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.02);

Gotchas worth memorizing

  • INCLUDE columns are payload only — not searchable, not sortable, not part of a UNIQUE constraint. WHERE status='paid' cannot use an INCLUDE'd status. Put it in the key if you filter on it.
  • Every extra column widens the index → more write amplification and less of it fits in cache. Cover 2-3 hot columns, not the whole row.
  • SELECT * can never be index-only. Covering indexes reward narrow SELECTs.
  • MySQL/InnoDB: no visibility map issue, and secondary indexes already carry the PK implicitly. Look for "Using index" in EXPLAIN Extra — that's the same win.

Your exercise

Take your slowest list/dashboard endpoint. Run EXPLAIN (ANALYZE, BUFFERS) on its query and note read=. Add an INCLUDE index for exactly the columns it selects, then run it twice: once right away, once after VACUUM.

What's Heap Fetches before and after the vacuum — and what does that tell you about how often that table is written?