Table and Index Bloat
partialSummary
Dead tuples from UPDATE and DELETE operations accumulate in PostgreSQL heap pages and index pages when autovacuum cannot reclaim them fast enough, causing table and index storage to grow well beyond the live data size and degrading query performance through wasted I/O on dead pages.
Description
PostgreSQL's MVCC implementation never overwrites an existing row. An UPDATE creates a new tuple version in the heap and marks the old version as dead. A DELETE marks the row as dead without removing the physical space. Dead tuples remain in the heap pages, occupying disk space and participating in sequential scans until VACUUM reclaims them.
VACUUM marks dead tuple space as reusable but does not compact pages or return disk space to the OS: that requires VACUUM FULL, which acquires ACCESS EXCLUSIVE lock and is operationally risky on large tables. Dead index entries pointing to dead tuples are also only cleaned up by VACUUM.
Bloat occurs when the dead tuple accumulation rate exceeds VACUUM's processing rate. The primary causes: autovacuum is triggered by dead tuple count thresholds (autovacuum_vacuum_threshold = 50 rows + autovacuum_vacuum_scale_factor × table size, default scale_factor = 0.2 meaning 20% of the table must be dead before vacuum triggers). For a 100M-row table, vacuum triggers after 20M dead tuples accumulate. A 10,000 updates/second workload generates 10,000 dead tuples/second and 864M dead tuples/day: far exceeding the threshold.
Long-running transactions prevent vacuum from advancing the oldest transaction horizon (relfrozenxid). Even if autovacuum runs, it cannot remove dead tuples visible to any open transaction. A single read-heavy analytics query holding a snapshot for 2 hours prevents all vacuum progress on any table written to in those 2 hours.
The operational consequence: table size grows without a corresponding growth in row count. Index size grows without new entries. Sequential scans read dead pages. Index scans return dead index entries that require heap fetches to validate, wasting I/O. Storage costs increase. Cache efficiency drops as the buffer pool caches dead pages at the expense of live data. Query performance degrades gradually over weeks as bloat grows.
This entire mechanism, heap dead tuples plus dead index entries reclaimed by VACUUM, is specific to PostgreSQL's storage model, and "run VACUUM" does not generalize to every engine. PostgreSQL uses a heap table that is not clustered by its primary key by default: every index, including the primary key index, is a separate structure pointing at heap tuple locations (ctid), so an UPDATE leaves dead space in both the heap and every index, and both need VACUUM.
InnoDB is structurally different: it is a clustered index engine, meaning the primary key index IS the table, rows are stored in primary-key order inside the B-Tree leaf pages themselves, there is no separate heap. "Bloat" in the PostgreSQL sense (a dead row occupying heap space until VACUUM) does not apply the same way, because InnoDB reclaims old row versions via its own undo log purge thread, not VACUUM (PostgreSQL has no purge thread and InnoDB has no VACUUM; the two are not interchangeable names for the same process). What InnoDB does experience is secondary-index fragmentation: every secondary index stores the indexed column plus the primary key value (not a physical row pointer), so a row that moves within the clustered index, or a page split from insert/update pressure, leaves secondary-index pages fragmented and requires an extra clustered-index lookup by primary key to fetch the full row. The operational fix is also different: OPTIMIZE TABLE rebuilds the table and its indexes, there is no InnoDB VACUUM to tune the way autovacuum_vacuum_scale_factor is tuned.
An LSM (log-structured merge-tree) engine, such as Cassandra's or ScyllaDB's SSTable storage, has no "bloat" in this sense at all, because it has no in-place update to leave dead space behind. A DELETE writes a tombstone, a marker that a key is deleted, rather than removing anything, and an UPDATE writes a new immutable SSTable entry rather than modifying one in place; older versions of a key and its tombstones persist across however many SSTables they landed in until compaction merges those SSTables and drops the obsolete versions. There is no VACUUM-equivalent command to run: reclamation happens only as a side effect of compaction, on compaction's schedule, not an operator-triggered one. The operational signal is also different: instead of a bloat ratio on a table, the signal is tombstone and SSTable accumulation, and a workload with heavy deletes and infrequent compaction can fail reads outright (Cassandra's tombstone_failure_threshold aborts a read that scans too many tombstones) rather than merely degrading, which has no PostgreSQL or InnoDB analogue. See lsm_compaction_debt for the compaction-backlog failure this produces.
Characteristics
Triggers
- ·High UPDATE or DELETE rate generating dead tuples faster than autovacuum can reclaim
- ·Long-running transactions (analytics queries, ETL jobs) holding snapshots and blocking vacuum horizon advance
- ·Autovacuum configuration too conservative for the table's update rate (default scale_factor unsuitable for large tables)
- ·Replication slots with lagging consumers retaining WAL and preventing vacuum from advancing xmin
- ·Rapid table growth outpacing autovacuum worker count (default 3 workers for all tables)
Detection Signals
Mitigation Strategies
Override per-table autovacuum settings for high-update tables: ALTER TABLE orders SET (autovacuum_vacuum_scale_factor = 0.01, autovacuum_vacuum_threshold = 1000). This triggers vacuum after 1% dead tuples instead of 20%, keeping bloat bounded for large tables.
Query pg_stat_activity for transactions older than 1 hour: SELECT pid, now() - xact_start AS duration, query FROM pg_stat_activity WHERE xact_start < now() - interval '1 hour'. Terminate long-running analytics queries or move them to replicas.
Lagging replication slots prevent vacuum from reclaiming dead tuples. SELECT slot_name, xmin, catalog_xmin FROM pg_replication_slots WHERE xmin IS NOT NULL AND age(xmin) > 1000000. Drop or pause consumer-inactive slots immediately.
pg_repack rebuilds tables and indexes online without ACCESS EXCLUSIVE lock, reclaiming dead space while the table remains available. Unlike VACUUM FULL, it does not block reads or writes. Run during off-peak hours for large tables.
Recovery Steps
- 1.Measure current bloat: run pgstattuple or pg_bloat query on top-N tables by size
- 2.Check pg_stat_activity for long-running transactions (>1 hour) and terminate safely
- 3.Check pg_replication_slots for inactive or lagging slots with held xmin
- 4.Trigger manual VACUUM ANALYZE on most bloated tables (not VACUUM FULL: avoids lock)
- 5.Tune autovacuum per-table settings for the most heavily updated tables
- 6.Schedule pg_repack for tables where bloat ratio exceeds 40% and manual vacuum is insufficient
Estimated recovery time: VACUUM on a bloated table runs at approximately 10–50GB/hour depending on disk I/O. A 100GB bloated table may take 2–10 hours to vacuum. pg_repack takes similar time. Tuning autovacuum settings takes effect immediately but prevents future bloat accumulation, not existing bloat.
Affected Systems
Patterns
Technologies
Basis
Precisely understood PostgreSQL MVCC behaviour with measurable bloat ratios and well-documented autovacuum tuning; long-transaction vacuum blocking is a well-known operational hazard. InnoDB clustered-index and secondary-index fragmentation behaviour, and LSM tombstone/compaction reclamation, are documented engine mechanisms; exact Cassandra tombstone_failure_threshold defaults should be confirmed against the specific version in use.
Sources & Claims
InnoDB's primary key index is the clustered index; the table's rows are stored in primary-key order inside the B-Tree itself, with no separate heap structure
pendingofficial documentation · MySQL 8.0 Reference Manual: InnoDB and the ACID Model / Clustered and Secondary Indexes
storage-engine-internals-spine batch 2
InnoDB secondary indexes store the indexed column value plus the primary key value, not a physical row pointer, so a secondary-index lookup requires a second lookup into the clustered index to fetch the full row
pendingofficial documentation · MySQL 8.0 Reference Manual: InnoDB Clustered and Secondary Indexes
storage-engine-internals-spine batch 2
InnoDB reclaims old row versions via a background purge thread operating on the undo log, a mechanism distinct from PostgreSQL's VACUUM; InnoDB has no VACUUM and PostgreSQL has no purge thread
pendingofficial documentation · MySQL 8.0 Reference Manual: InnoDB Undo Logs, Purge
storage-engine-internals-spine batch 2
In LSM-tree engines such as Cassandra, a DELETE writes a tombstone rather than removing data, and obsolete versions and tombstones are reclaimed only during compaction, not by an operator-triggered command
pendingofficial documentation · Cassandra documentation: compaction and tombstones
storage-engine-internals-spine batch 2
Cassandra's tombstone_failure_threshold aborts a read that scans past a configured number of tombstones, rather than merely degrading
pendingofficial documentation · Cassandra documentation: tombstone_failure_threshold (cassandra.yaml)
storage-engine-internals-spine batch 2; exact default value not asserted here, confirm against version in use
Run This Failure
Blast radius analysis for this failure mode within each scenario that carries it.
Related Architecture Knowledge
Inbound: affects this entity
Write-heavy transactional workloads cause index bloat over time: dead tuples from updates and deletes leave stale entries in B-tree indexes that are not immediately reclaimed, causing indexes to grow larger than their live data size and degrading read performance.
Tradeoffs
- ·Aggressive autovacuum consumes I/O and CPU: may contend with production query load during business hours
- ·REINDEX CONCURRENTLY holds an AccessShareLock: does not block reads but does block DDL
- ·Partitioning by time enables DROP PARTITION as an efficient alternative to autovacuum on old data
Used In Architecture Scenarios
AI / RAG Application
A Retrieval-Augmented Generation (RAG) architecture that combines vector similarity search for semantic document retrieval with relational metadata filtering, using PostgreSQL with pgvector as the unified store for both embeddings and structured data. Redis provides a semantic cache to avoid redundant embedding model inference and reduce vector index query load for repeated or similar queries. Kafka manages the asynchronous embedding generation pipeline that keeps the vector index current as source documents are added or updated.
Read-Heavy Application
A CMS for publishing and serving structured content: articles, documentation, product pages, and localized variants: where read APIs serve 50–100x more traffic than editorial write APIs. PostgreSQL stores the content graph (articles, authors, categories, taxonomy) and workflow state (draft, in-review, scheduled, published). Redis caches published content objects for read APIs. Elasticsearch powers full-text content search with faceting and relevance ranking. Cache invalidation on publish must be fast and complete; N+1 query patterns on content relationship traversal are the dominant database performance risk during reads.
Search-Heavy Application
A content platform architecture centered on Elasticsearch for full-text search, faceted navigation, and ranked results, with PostgreSQL as the transactional source of truth and Redis for session management and hot content caching. WAL-based CDC maintains index freshness by streaming PostgreSQL changes into Elasticsearch asynchronously. The core tension is between search index freshness, query performance, and index maintenance cost under high write volume.