DEV Community

SoftwareDevs mvpfactory.io
SoftwareDevs mvpfactory.io

Posted on • Originally published at mvpfactory.io

PostgreSQL Index-Only Scans and Visibility Maps: The Query Optimization Layer Most Developers Never Reach

---
title: "PostgreSQL Index-Only Scans: The Hidden VACUUM Dependency Your Covering Indexes Depend On"
published: true
description: "Why your PostgreSQL covering indexes silently fall back to heap access  and how to tune VACUUM and visibility maps for high-write SaaS workloads."
tags: postgresql, performance, architecture, devops
canonical_url: https://mvpfactory.co/blog/postgresql-index-only-scans-visibility-map
---

## What we will cover

Today you will learn why your covering index is lying to you. `EXPLAIN` says `Index Only Scan`. The plan looks clean. But heap fetches are still happening — silently, expensively — and your query performance is paying the price. We are going to diagnose this using real query plans, fix it with targeted autovacuum tuning, and build a weekly monitoring query you can drop straight into production.

(Side note: if you are the kind of developer who goes deep on database internals, you probably spend long hours at your desk. I have been using [HealthyDesk](https://play.google.com/store/apps/details?id=com.healthydesk) for break reminders and guided desk exercises — worth keeping in your toolkit alongside your SQL profiling habits.)

## Prerequisites

- PostgreSQL 13+ (examples run on PG 15)
- Access to `pg_stat_user_tables` and `pg_stat_progress_vacuum`
- A table with meaningful write volume — event logs and audit tables are the most common offenders

---

## Step 1 — Understand what index-only scans actually require

Here is the pattern I explain in every PostgreSQL review I do. Developers learn requirement one: all columns in the query must exist in the index. Create a covering index, ship it, done.

They miss requirement two — and this is the one that kills you in production.

PostgreSQL must confirm tuple visibility **without touching the heap**. It does this via the **visibility map** — a compact bitmap, one bit per heap page. When the all-visible bit is set, VACUUM has confirmed every tuple on that page is visible to all transactions. Index-only scan proceeds cleanly. When that bit is not set — because writes have dirtied the page since the last VACUUM — PostgreSQL falls back to a heap fetch. Your "optimized" query just became a regular index scan with extra steps.

## Step 2 — Read the signals in EXPLAIN and pg_stat_user_tables

Run this on your target query:

Enter fullscreen mode Exit fullscreen mode


sql
EXPLAIN (ANALYZE, BUFFERS)
SELECT user_id, created_at FROM events
WHERE tenant_id = 42 AND created_at > now() - interval '7 days';


Watch for this in the output:

Enter fullscreen mode Exit fullscreen mode


sql
Index Only Scan using idx_events_covering on events
Heap Fetches: 18402
Buffers: shared hit=4821 read=3107


Non-zero `Heap Fetches` is the smoking gun. Now cross-reference with `pg_stat_user_tables`:

Enter fullscreen mode Exit fullscreen mode


sql
SELECT
relname,
n_live_tup,
n_dead_tup,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_ratio_pct,
last_autovacuum,
last_vacuum,
n_mod_since_analyze
FROM pg_stat_user_tables
WHERE relname = 'events'
ORDER BY n_dead_tup DESC;


If `dead_ratio_pct` is above 5% and `last_autovacuum` is hours or days old on an active table, you have found your problem.

## Step 3 — Tune autovacuum for high-write SaaS workloads

The docs do not make this obvious, but autovacuum's defaults were designed for moderate OLTP workloads — not a SaaS backend processing thousands of events per minute. The default `autovacuum_vacuum_scale_factor` of 0.20 means autovacuum will not trigger on a 10-million-row table until 2 million rows have been modified. That is a long window to accumulate dead tuples.

Here is the minimal setup to get this working for high-write tables:

| Parameter | Default | High-Write Recommendation |
|---|---|---|
| `autovacuum_vacuum_scale_factor` | 0.20 | 0.01–0.05 |
| `autovacuum_vacuum_threshold` | 50 rows | 100–500 rows |
| `autovacuum_vacuum_cost_delay` | 2ms | 0–1ms |
| `autovacuum_max_workers` | 3 | 5–8 |
| `autovacuum_naptime` | 1 min | 15–30 sec |

Apply this per-table rather than globally — more surgical, fewer surprises:

Enter fullscreen mode Exit fullscreen mode


sql
ALTER TABLE events SET (
autovacuum_vacuum_scale_factor = 0.02,
autovacuum_vacuum_cost_delay = 1
);


## Step 4 — Prime the visibility map and verify

Once autovacuum is tuned, run a manual VACUUM to recover existing tables immediately:

Enter fullscreen mode Exit fullscreen mode


sql
VACUUM (ANALYZE, VERBOSE) events;


Monitor large tables with `pg_stat_progress_vacuum`. Then re-run your `EXPLAIN (ANALYZE, BUFFERS)`. On a 50-million-row events table ingesting roughly 8,000 writes per minute on PostgreSQL 15 (c5.2xlarge, gp3 storage), this dropped heap fetches from ~20,000 per query to under 50, with query latency falling by approximately 60%.

Let me show you a pattern I use in every production database I maintain — a weekly diagnostic query to stay ahead of this:

Enter fullscreen mode Exit fullscreen mode


sql
SELECT
relname,
pg_size_pretty(pg_total_relation_size(relid)) AS total_size,
round(100.0 * n_dead_tup / nullif(n_live_tup + n_dead_tup, 0), 2) AS dead_pct,
age(relfrozenxid) AS xid_age,
last_autovacuum
FROM pg_stat_user_tables
JOIN pg_class ON pg_stat_user_tables.relid = pg_class.oid
WHERE schemaname = 'public'
AND n_live_tup > 10000
ORDER BY dead_pct DESC NULLS LAST
LIMIT 20;


---

## Gotchas

**`EXPLAIN` without `ANALYZE` will not show heap fetches.** The node name `Index Only Scan` appears in the plan regardless of whether heap fetches are happening at runtime. Always use `EXPLAIN (ANALYZE, BUFFERS)` to get actual execution data.

**Global autovacuum changes affect every table.** Tune per-table with `ALTER TABLE ... SET (...)` on your hottest tables first. Avoid changing cluster-wide settings without understanding your full workload mix.

**A freshly created covering index is not enough.** Existing heap pages will not have their all-visible bits set until VACUUM runs. After adding a covering index to a large, active table, run `VACUUM ANALYZE` manually before expecting index-only scan to deliver its full benefit.

---

## Conclusion

Covering indexes are only half the story. The visibility map is the invisible layer between your index strategy and actual query performance. Verify with `EXPLAIN (ANALYZE, BUFFERS)`, tune autovacuum per-table with a scale factor of 1–5% on write-heavy workloads, and treat VACUUM as a first-class performance concern rather than a maintenance afterthought. Your event logs and audit tables will thank you.

**Resources:**
- [PostgreSQL VACUUM documentation](https://www.postgresql.org/docs/current/sql-vacuum.html)
- [pg_stat_user_tables reference](https://www.postgresql.org/docs/current/monitoring-stats.html#MONITORING-PG-STAT-ALL-TABLES-VIEW)
- [Autovacuum tuning guide (PostgreSQL wiki)](https://wiki.postgresql.org/wiki/Autovacuum)
Enter fullscreen mode Exit fullscreen mode

Top comments (0)