🌐 Language: English Version | 中文版
During a production database capacity review, we ran into a typical PostgreSQL index bloat case: several tables did not contain much live data, and one table had no live rows at all, yet their index files still occupied a large amount of disk space. Ordinary VACUUM had already run. n_dead_tup did not look alarming. The database files, however, did not shrink.
This kind of issue is easy to misread as “the database needs cleanup.” The real question is more specific: is the space pressure coming from heap bloat or index bloat? This article focuses on the diagnosis path: why the issue happens, why VACUUM did not fix it, what can stop the bleeding in the short term, and what should change in the write model over time.
TL;DR
- Ordinary PostgreSQL
VACUUMcan clean dead tuples in heap pages and make that space reusable, but it does not automatically shrink already-bloated B-tree index files. - When table data is small but indexes are huge, first separate heap bloat from index bloat. Looking only at
n_dead_tupis not enough. - Frequently deleted, rebuilt, and batch-updated derived tables can create long-term index write amplification when they are stored as heavily indexed primary business tables.
- Short-term remediation usually means dropping confirmed-unused indexes and rebuilding bloated ones with
REINDEX CONCURRENTLY. - Long-term remediation must change the write model: use staging + diff instead of broad
DELETE+bulk_create, and make index lifecycle review part of engineering practice.
1. The Symptom: Small Tables, Huge Indexes
The first step in a capacity investigation is not to run a cleanup script. It is to split table data size from index size.
1 | SELECT |
The shape of the problem looked roughly like this:
| Table type | Total size | Table data | Indexes | Estimated rows |
|---|---|---|---|---|
| Periodic derived table | 50+ GB | A few MB | 50+ GB | Tens of thousands |
| Rule detail table | Several GB | Hundreds of KB | Several GB | Thousands |
| Execution record table | Hundreds of MB | 0 bytes | Hundreds of MB | 0 |
| Extended attribute table | Hundreds of MB | Hundreds of KB | Hundreds of MB | Thousands |
That shape tells us the issue is not “too much live business data.” Historical write behavior has inflated index files.
Then check autovacuum statistics:
1 | SELECT |
This is where misdiagnosis often starts. Some tables may already have n_dead_tup = 0, so it is tempting to conclude that autovacuum has handled the bloat. But if disk usage is still high, the main issue is probably not the heap. It is the indexes.
Next, inspect index size and usage:
1 | SELECT |
Common findings:
- A single B-tree index can grow to tens of GB.
- Some indexes have
idx_scan = 0but still occupy significant disk space. - Some tables may be empty while their indexes still exist on disk.
- Primary key indexes, time-based indexes, and ORM-generated foreign-key indexes can all be inflated by historical churn.
At this point, continuing to run ordinary VACUUM (ANALYZE) usually will not produce the expected result.
2. Why Ordinary VACUUM Did Not Shrink the Indexes
PostgreSQL uses MVCC. An UPDATE does not overwrite a row in place. It writes a new row version and marks the old version as no longer visible. A DELETE also does not immediately return physical space to the operating system. It first creates reclaimable space.
The main job of ordinary VACUUM is to clean dead tuples in heap pages and make that space reusable inside the same table file. It can also remove index entries pointing to dead tuples. What it does not do is automatically shrink an already-expanded B-tree index file back to a small file.
So the database can reach this state:
n_dead_tup = 0- table data is only a few MB
- indexes are still tens of GB
- repeated
VACUUMruns do not materially reduce disk usage
That does not mean VACUUM failed. It means VACUUM solved a different problem from the one we were facing.
If the goal is to shrink bloated index files, typical options are:
REINDEX INDEX CONCURRENTLY index_nameREINDEX TABLE CONCURRENTLY table_name- a table/index rewrite tool such as
pg_repack
VACUUM FULL can also rewrite objects and reclaim space, but it requires heavier locking and is usually not the first choice for online systems. Since PostgreSQL 12, REINDEX CONCURRENTLY is a practical option for online index remediation. It still consumes IO, and the old and new indexes coexist for a period of time, so disk headroom must be checked before execution.
3. Root Cause: High-Churn Derived Data Stored Like Stable Primary Data
The root cause is rarely one database parameter. It is usually a mismatch between the write model and the storage model.
Consider derived data such as reporting results, rule-hit details, execution windows, and temporary computation outputs. Their characteristics are often similar:
- upstream data refreshes periodically
- configuration changes can trigger broad recalculation
- historical periods expire
- calculation rules may change and require recomputing a future range
This data is naturally high churn. But in many systems it is stored as long-lived primary data, with many indexes added for read paths. The write model then becomes:
1 | Delete derived records for a target object over a future range |
Or:
1 | Calculation rule changes |
At a small scale this is simple and usually correct. Under PostgreSQL MVCC, however, running this pattern for a long time continuously creates dead tuples and index garbage. The more important point is write amplification: every extra B-tree index must be maintained during deletes, inserts, and updates. The application thinks it is only rebuilding a derived result set. The database is maintaining multiple indexes for every rewrite.
This is why simply making autovacuum more aggressive is not enough. Autovacuum can reduce accumulation. It cannot remove the root cause of storing high-churn derived data as heavily indexed long-lived primary data.
4. Another Root Cause: ORM Defaults Plus Accumulated Business Indexes
Another common source is that indexes are not designed as a whole. They accumulate over time.
For example:
- an ORM creates a single-column index for a foreign-key field
- a business query adds a composite index
- an ordering column gets
db_index=True - a unique constraint creates its own index
- a historical LIKE query adds another index
- the query pattern changes later, but the old index is never removed
A high-write table may end up with indexes like:
1 | (entity_id) |
Some of these indexes may still serve hot queries. Some may be historical leftovers. Some may be covered by the prefix of a composite index.
Do not drop an index only because idx_scan = 0. At minimum, check whether statistics have just been reset:
1 | SELECT datname, stats_reset |
Also confirm:
- whether the index backs a unique constraint or primary key
- whether it is required by foreign-key maintenance
- whether low-frequency but critical background jobs use it
- whether ORM-generated query paths depend on it
- whether an existing composite index already covers it
- whether key queries regress under
EXPLAIN
A safer workflow is to observe candidate indexes over a meaningful period, combine pg_stat_user_indexes with slow-query logs and EXPLAIN, and only then remove confirmed-unused indexes with DROP INDEX CONCURRENTLY.
5. Short-Term Remediation: Drop Unused Indexes and Rebuild Bloated Ones
Short-term remediation has three parts.
First, record the before state.
1 | SELECT |
Also check long-running transactions and invalid indexes:
1 | SELECT |
Second, drop confirmed-unused indexes:
1 | DROP INDEX CONCURRENTLY IF EXISTS idx_unused_column; |
CONCURRENTLY reduces the impact on normal reads and writes, but it still consumes database resources. Process a small number of indexes at a time and watch database IO and application metrics.
Third, rebuild bloated indexes online:
1 | REINDEX INDEX CONCURRENTLY idx_large_active_index; |
If multiple indexes on the same table are bloated, evaluate:
1 | REINDEX TABLE CONCURRENTLY high_churn_table; |
Operational notes:
REINDEX CONCURRENTLYcannot run inside a transaction block.- If it fails, it may leave an invalid
_ccnewindex that must be cleaned up before retrying. - The old and new indexes coexist during the rebuild, so disk headroom is required.
- Reindexing creates IO pressure and should be batched during low-traffic windows.
- After rebuilding, compare object sizes and key query plans again.
Use the same query for before/after validation. Do not change the measurement after remediation:
1 | SELECT |
Then sample core queries with EXPLAIN ANALYZE to ensure that index cleanup did not introduce obvious query regressions.
6. Medium-Term Governance: Give Indexes a Lifecycle
Index cleanup should not start only after disk alerts. A useful medium-term practice has four parts.
First, monitor index size and usage.
Track:
- individual index size
- table-data-to-index-size ratio
idx_scangrowthidx_tup_read/idx_tup_fetchpg_stat_database.stats_reset
If statistics were reset recently, idx_scan = 0 only means “not used since the reset.” It does not prove long-term non-use.
Second, tune autovacuum per high-churn table.
Global defaults are often conservative. For frequently updated tables, table-level reloptions can be more appropriate:
1 | ALTER TABLE high_churn_table SET ( |
If updated columns are not indexed, consider fillfactor to leave room for HOT updates:
1 | ALTER TABLE high_churn_table SET ( |
Third, create a low-traffic REINDEX procedure.
Not every index needs scheduled rebuilding. But indexes on known high-churn tables should have a review rule:
- bloat ratio crosses a threshold
- an individual index crosses a size threshold
- write/delete volume remains high
- a code change is planned and the table already shows bloat symptoms
Fourth, clean up temporary backup tables.
Many databases accumulate _backup, _bak, _copy, or date-suffixed tables. They may not cause index bloat directly, but they consume capacity and add noise to investigations. If a temporary backup table enters the main database, it needs a retention window, an owner, and a cleanup rule.
7. Long-Term Remediation: Reduce Write Amplification
Database-level REINDEX can fix already-bloated files. It cannot prevent recurrence. Long-term remediation has to change the write model.
For periodic derived data, staging + diff is usually a better pattern:
1 | Upstream raw data |
The key is to stop deleting and rebuilding a broad future range on every run. Only rows that actually changed should be written.
Configuration screens should follow the same idea. Saving configuration should distinguish:
- normal state, threshold, or priority changes: update by primary key or business key
- incompatible structural changes: clear and rebuild only when necessary
- expired historical data: archive or partition cleanup, not unbounded deletes on the primary table
If data has a natural time lifecycle, partitioning may also be worth evaluating:
- current and future periods stay in active partitions
- historical partitions become read-only or archived
- data beyond retention is removed with
DROP PARTITION - broad deletes on a large primary table are avoided
Finally, index design should enter engineering review. Each new index should answer:
- which interface or SQL does it serve
- what are the typical
WHERE,JOIN, andORDER BYclauses - is it covered by the prefix of an existing composite index
- is it on a high-churn table
- how will it be verified after release with
pg_stat_user_indexesandEXPLAIN - who owns its removal if the query path disappears
Indexes are not free. They trade space and write cost for read performance. More indexes may be acceptable on read-heavy stable tables. On high-churn derived tables, every additional index is a persistent write-amplification multiplier.
8. Takeaways
The visible problem was PostgreSQL index bloat. The deeper issue was the combination of write-model design, ORM-generated defaults, accumulated business indexes, and missing operational lifecycle.
The short-term path is:
1 | Find large objects |
The long-term changes are more important:
- Do not store frequently rebuilt derived data as if it were stable primary data.
- Do not allow ORM defaults and business indexes to accumulate without review.
- Do not treat
idx_scan = 0as the only signal for dropping an index. - Do not wait for disk alerts before maintaining database objects.
- Do not use broad
DELETE+bulk_createwhen a diff-based write model would work.
When a table with only a few MB of data carries tens of GB of indexes, the issue is no longer just “clean up the database.” It is evidence that the data model, write path, index design, and maintenance process need to be designed together.