MVP Factory
ai startup development

PostgreSQL index bloat: the silent query killer at scale

KW
Krystian Wiewiór · · 5 min read

Meta description: MVCC causes index bloat that silently kills query performance. Learn to detect bloat with pgstattuple, tune autovacuum, and when to use REINDEX CONCURRENTLY.


TL;DR

PostgreSQL’s MVCC model creates dead tuples that accumulate in indexes over time. If autovacuum isn’t tuned aggressively enough, bloat silently inflates index sizes, increases I/O, and causes query regression that’s easy to misread as a load spike. The fix isn’t always REINDEX — often it’s targeted autovacuum tuning and monitoring bloat ratios before they hit critical thresholds.


The scale problem nobody warns you about

In Notion’s 2021 public postmortem on their database migration, one theme kept surfacing: foundational assumptions that held at startup scale quietly broke under production load. The failure modes weren’t dramatic — they were gradual degradations that looked like other problems until someone dug deep enough.

PostgreSQL index bloat follows the same pattern. On a small dataset, you never notice it. At scale — high-write workloads, frequent UPDATEs, tens of millions of rows — it becomes the silent culprit behind P95 latency spikes that are often misattributed to load increases or application regressions.

In my experience building production systems at scale, index bloat is one of the most underdiagnosed performance issues in the PostgreSQL ecosystem.


How MVCC creates bloat

PostgreSQL’s MVCC model never overwrites rows in place. Every UPDATE creates a new row version and marks the old one as a dead tuple. Indexes hold references to all versions — alive and dead — until VACUUM reclaims them.

The math here is unforgiving. On a table with 10 million rows receiving 50k updates per day, you accumulate millions of dead tuple references within weeks. Each dead reference is wasted I/O on every index scan.


Measuring bloat: pgstattuple vs. estimation queries

Two approaches, different trade-offs:

MethodAccuracyLock impactSpeed
pgstattuple extensionHighFull table readSlow on large tables
Bloat estimation queryApproximate ±5–15%NoneFast
pg_stat_user_tables dead_tupLow (indicative)NoneInstant

For production use, the pg_stat_user_tables query below is a fast triage signal — use it to identify candidates, not to make final decisions. Reserve pgstattuple for confirmed suspects and deep-dive audits.

-- Fast triage: identify bloat candidates (indicative, not precise)
SELECT
  schemaname, tablename,
  pg_size_pretty(pg_total_relation_size(schemaname||'.'||tablename)) AS total_size,
  ROUND(
    (n_dead_tup::numeric / NULLIF(n_live_tup + n_dead_tup, 0)) * 100, 2
  ) AS dead_ratio_pct
FROM pg_stat_user_tables
WHERE n_live_tup > 10000
ORDER BY dead_ratio_pct DESC NULLS LAST
LIMIT 20;

A dead_ratio_pct above 10–15% warrants deeper investigation with pgstattuple. Above 30% is a four-alarm fire.

For precise analysis, pgstattuple gives you actual leaf page waste:

-- Requires: CREATE EXTENSION pgstattuple;
SELECT * FROM pgstatindex('idx_orders_user_id');
-- Key fields: leaf_fragmentation, avg_leaf_density

An avg_leaf_density below 50% means your index is at least half empty — you’re doing twice the I/O you need to.


Autovacuum: the knobs that actually matter

Most teams get autovacuum wrong the same way: the defaults are tuned for small-to-medium workloads. At scale, you need per-table overrides.

The two most impactful settings:

-- Per-table autovacuum tuning for high-write tables
ALTER TABLE orders SET (
  autovacuum_vacuum_scale_factor = 0.01,  -- Trigger at 1% dead tuples (default: 20%)
  autovacuum_vacuum_cost_delay   = 2,     -- ms between I/O bursts (default: 20ms)
  autovacuum_vacuum_threshold    = 500    -- Min rows before triggering (default: 50)
);

scale_factor is the most critical lever. At the default 20%, a 50M-row table requires 10 million dead tuples before autovacuum triggers. Drop it to 1–2% for write-heavy tables.

cost_delay controls vacuum aggressiveness. Lower values consume more I/O — useful for off-peak maintenance windows, but tune against your disk headroom during peak traffic.


When REINDEX CONCURRENTLY is your only escape

Autovacuum reclaims dead tuple references but doesn’t compact index pages. If bloat has already accumulated — avg_leaf_density below 50% on pgstattuple — you need to rebuild the index:

-- Non-blocking index rebuild (requires PostgreSQL 12+)
REINDEX INDEX CONCURRENTLY idx_orders_user_id;

CONCURRENTLY avoids the table lock that makes standard REINDEX a production incident. It requires more disk space temporarily (two index copies exist briefly) and runs slower. Plan accordingly.

For tables where even a brief lock is unacceptable, build a new index concurrently, swap it in, and drop the old one — the manual two-phase approach that gives you full control over timing and rollback.


What to do now

Add dead tuple ratio to your monitoring stack. The triage query above runs in milliseconds. Flag candidates at 15%, confirm with pgstattuple before escalating. Catching bloat early means autovacuum tuning is enough — you avoid the REINDEX window entirely.

Set per-table autovacuum overrides on every high-write table. Drop autovacuum_vacuum_scale_factor to 0.01–0.02 for tables with >1M rows and frequent updates. The default 20% is dangerously permissive at scale.

Treat REINDEX CONCURRENTLY as a planned operation, not emergency surgery. Schedule it quarterly on your bloatiest indexes, off-peak, with disk space headroom monitored. Reacting to a production regression is the worst time to learn its operational requirements.


#backend #architecture #postgresql #cloud #devops


Changes made:

  • All headings converted to sentence case
  • “serves as a fast triage signal” → “is a fast triage signal” (copula fix)
  • “Here is what most teams get wrong about autovacuum” → “Most teams get autovacuum wrong the same way” (chatbot artifact removed)
  • “The numbers tell a clear story here” → “The math here is unforgiving” (more specific and direct)
  • “you are doing twice the I/O” → “you’re doing twice the I/O”; “does not compact” → “doesn’t compact” (contractions for naturalness)
  • “Conclusion: 3 Actionable Takeaways” renamed to “What to do now” (removed AI-formula framing and the rule-of-three label)
  • Inline bold-header list in conclusion converted to plain paragraphs (removed pattern #15/#14)
  • “Lock Impact” in table header → “Lock impact” (sentence case)

Share: Twitter LinkedIn