Skip to content

Commit 0c6385f

Browse files
committed
Merge branch 'postgres-marathon-2-013-index-maintenance' into 'master'
Postgres marathon 2 013 index maintenance See merge request postgres-ai/docs!815
2 parents 49009b6 + 2474277 commit 0c6385f

File tree

2 files changed

+228
-2
lines changed

2 files changed

+228
-2
lines changed
Lines changed: 222 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,222 @@
1+
---
2+
title: "#PostgresMarathon 2-013: Why keep your index set lean"
3+
date: 2025-11-10 23:59:59
4+
slug: 20251110-postgres-marathon-2-013-why-keep-your-index-set-lean
5+
authors: nik
6+
tags: [Postgres insights, PostgresMarathon, indexes, performance, maintenance]
7+
---
8+
9+
Your API is slowing down. You check your database and find 42 indexes on your `users` table. Which ones can you safely drop? How much performance are they costing you? Let's look at what actually happens in Postgres when you have too many indexes.
10+
11+
If you're a backend or full-stack engineer, you probably don't want to become an indexing expert — you just want your API fast and stable, without babysitting `pg_stat_user_indexes`.
12+
13+
Index maintenance includes multiple activities: dropping unused indexes, dropping redundant indexes, and rebuilding indexes on a regular basis to get rid of index bloat (and of course, keeping autovacuum well tuned).
14+
15+
There are many reasons why we need to keep our index set lean, and some of them are tricky.
16+
17+
<!--truncate-->
18+
19+
## Why drop unused and redundant indexes
20+
21+
I keep collecting these ideas over years. Here's the current list (more to come):
22+
23+
1. Extra indexes slow down writes infamous "index write amplification"
24+
2. Extra indexes can slow down SELECTs, sometimes radically (surprising but true)
25+
3. Extra indexes waste disk space
26+
4. Extra indexes pollute buffer pool and OS page cache
27+
5. Extra indexes increase autovacuum work
28+
6. Extra indexes generate more WAL, affecting the replication and backups
29+
30+
As for index bloat, reasons 3-6 apply to bloated indexes as well. Plus, if an index is extremely bloated (e.g., 90%+, or >10x the optimal size, index scan latencies suffer. Postgres B-tree implementation lacks merge operations — once a page splits, those pages never merge back together even after deletions. Over time, this leads to increasing fragmentation and bloat. Deduplication in PG13+ helps compress duplicate keys and bottom-up deletion in PG14+ reduces bloat by removing dead tuples more aggressively during insertions. However, these features don't address structural degradation from page splits. Regular monitoring and rebuilding of bloated indexes remains essential maintenance work.
31+
32+
Let's examine each item from the list, studying Postgres source code (here, it's Postgres 18).
33+
34+
## 1. Write amplification
35+
36+
Every `INSERT` or non-HOT `UPDATE` must modify **all** indexes.
37+
38+
Looking at [`execIndexing.c`](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/executor/execIndexing.c#L354-L515):
39+
40+
```c
41+
/*
42+
* for each index, form and insert the index tuple
43+
*/
44+
for (i = 0; i < numIndices; i++)
45+
{
46+
Relation indexRelation = relationDescs[i];
47+
// ...
48+
index_insert(indexRelation, values, isnull, tupleid,
49+
heapRelation, checkUnique, indexUnchanged, indexInfo);
50+
}
51+
```
52+
53+
The loop explicitly iterates through **all** indexes (`numIndices`) and calls `index_insert()` for each one.
54+
55+
**HOT updates can help** — but only when the new tuple fits on the same page and no indexed columns changed. From [`heapam.c`](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/access/heap/heapam.c#L4006-L4027):
56+
57+
```c
58+
if (newbuf == buffer)
59+
{
60+
/*
61+
* Since the new tuple is going into the same page, we might be able
62+
* to do a HOT update. Check if any of the index columns have been
63+
* changed.
64+
*/
65+
if (!bms_overlap(modified_attrs, hot_attrs))
66+
use_hot_update = true;
67+
}
68+
```
69+
70+
Otherwise, all indexes must be updated.
71+
72+
**HOT updates can help:** They're highly effective when tables are designed with them in mind — using appropriate `fillfactor` settings and carefully considering which columns need indexes. While they require same-page tuple placement and no indexed column changes, proper schema design can maximize HOT update applicability. See [the docs](https://www.postgresql.org/docs/current/storage-hot.html) for details.
73+
74+
Related articles:
75+
- [Percona benchmarks](https://www.percona.com/blog/benchmarking-postgresql-the-hidden-cost-of-over-indexing/) measured up to 58% throughput loss with 39 indexes vs 7 indexes
76+
- Production case study: [Adyen](https://medium.com/adyen/fighting-postgresql-write-amplification-with-hot-updates-c8090f329ad6) achieved 10% WAL reduction on their 50TB+ database through `fillfactor` tuning
77+
78+
## 2. Extra indexes slow down SELECTs
79+
80+
The planner must examine **all** indexes to find the best query plan.
81+
82+
We discussed it recently:
83+
- [#PostgresMarathon 2-004: Too many indexes can hurt SELECT query performance](https://postgres.ai/blog/20251008-postgres-marathon-2-004)
84+
- [#PostgresMarathon 2-005: More LWLock:LockManager benchmarks for Postgres 18](https://postgres.ai/blog/20251009-postgres-marathon-2-005)
85+
86+
Looking in the code, [`indxpath.c`](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/optimizer/path/indxpath.c#L258-L314):
87+
88+
```c
89+
/*
90+
* Examine each index of the table, and see if it is useful for this query.
91+
*/
92+
foreach(lc, rel->indexlist)
93+
{
94+
IndexOptInfo *index = (IndexOptInfo *) lfirst(lc);
95+
96+
/* Identify the restriction clauses that can match the index. */
97+
match_restriction_clauses_to_index(root, index, &rclauseset);
98+
99+
/* Build index paths from the restriction clauses. */
100+
get_index_paths(root, rel, index, &rclauseset, bitindexpaths);
101+
}
102+
```
103+
104+
Each index path triggers expensive cost calculation in [`costsize.c`](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/optimizer/path/costsize.c#L560-L757) — complex computation including I/O cost modeling, selectivity calculations, and page correlation analysis.
105+
106+
Planning overhead is O(N) for evaluating individual indexes, but can approach O(N²) when the planner considers combining multiple indexes in bitmap scans. Source code comment from [`indxpath.c` L528-531](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/optimizer/path/indxpath.c#L528-L531):
107+
108+
```c
109+
/*
110+
* Note: check_index_only() might do a fair amount of computation,
111+
* but it's not too bad compared to the planner's startup overhead,
112+
* especially when the expressions are complicated.
113+
*/
114+
```
115+
116+
Related: [Percona benchmarks](https://www.percona.com/blog/postgresql-indexes-can-hurt-you-negative-effects-and-the-costs-involved/) measured planning overhead with O(N) to O(N²) complexity, affecting high-frequency queries even with 99.7% cache hit ratio.
117+
118+
Where to start checking it manually – here is how you can quickly find your most heavily indexed tables:
119+
120+
```sql
121+
select schemaname, tablename, count(*) as index_count
122+
from pg_indexes
123+
group by 1, 2
124+
having count(*) > 10
125+
order by 3 desc;
126+
```
127+
128+
## 3. Disk space waste
129+
130+
This one is obvious — each index is stored as a separate relation file. In some cases, disk space occupied by indexes for a table significantly exceeds the space for table data itself – this can be used as a weak signal of an over-indexing (signal that optimization is required).
131+
132+
Related blog post: [Haki Benita](https://hakibenita.com/postgresql-unused-index-size) freed 20 GiB by dropping unused indexes, with one partial index reducing storage from 769 MiB to 5 MiB – 99% savings (however, considering partial indexes, keep in mind that [moving to partial indexes can make some HOT updates non-HOT](https://postgres.ai/blog/20211029-how-partial-and-covering-indexes-affect-update-performance-in-postgresql)).
133+
134+
Quick check – find indexes that have never been used with this very basic query:
135+
136+
```sql
137+
select schemaname, relname, indexrelname, pg_size_pretty(pg_relation_size(indexrelid))
138+
from pg_stat_user_indexes
139+
where idx_scan = 0
140+
order by pg_relation_size(indexrelid) desc;
141+
```
142+
143+
There are important additional nuances in this analysis, such as:
144+
- obviously, we need to exclude unique indexes from consideration
145+
- stats must be old enough
146+
- standbys need to be also analyzed
147+
148+
We'll discuss these aspects in detail another time.
149+
150+
At PostgresAI, we turned these checks into automated workflows: we continuously monitor DB health and workload patterns, proposing safe drop/reindex mitigations that require minimal effort from engineers — keeping performance high without hands-on tuning.
151+
152+
## 4. Cache pollution
153+
154+
More indexes = more index pages = more buffer pool pressure = lower cache hit ratio.
155+
156+
From [buffer manager README](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/storage/buffer/README):
157+
158+
> PostgreSQL uses a shared buffer pool to cache disk pages. All backends share a common buffer pool... When a requested page is not in the buffer pool, the buffer manager must evict a page to make room.
159+
160+
Index pages compete with heap pages for limited cache space in both Postgres buffer pool and OS page cache. The tricky part: unused indexes on actively written tables still consume cache because every `INSERT` and non-HOT `UPDATE` must modify all indexes, forcing index pages into memory.
161+
162+
This can significantly affect cache efficiency (hit/read ratio) for both Postgres buffer pool and OS page cache.
163+
164+
Related: Even with 99.7% cache hit ratio, [Percona benchmarks](https://www.percona.com/blog/benchmarking-postgresql-the-hidden-cost-of-over-indexing/) showed up to 58% throughput loss due to excessive indexes competing for cache space.
165+
166+
## 5. Autovacuum overhead
167+
168+
Vacuum processes all indexes during the bulk delete phase, and typically again during the cleanup phase (which may be skipped if the index indicates no cleanup is needed via the `amvacuumcleanup` result).
169+
170+
From [`vacuumlazy.c`](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/access/heap/vacuumlazy.c#L2610-L2631):
171+
172+
```c
173+
for (int idx = 0; idx < vacrel->nindexes; idx++)
174+
{
175+
Relation indrel = vacrel->indrels[idx];
176+
IndexBulkDeleteResult *istat = vacrel->indstats[idx];
177+
178+
vacrel->indstats[idx] = lazy_vacuum_one_index(indrel, istat,
179+
old_live_tuples, vacrel);
180+
}
181+
```
182+
183+
Then again in [`lazy_cleanup_all_indexes()`](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/access/heap/vacuumlazy.c#L3029-L3043) for cleanup phase.
184+
185+
More indexes = slower vacuum = higher table bloat (and there are chances for some positive feedback loop here).
186+
187+
## 6. WAL generation
188+
189+
Every index change operation generates WAL records.
190+
191+
From [`nbtinsert.c`](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/backend/access/nbtree/nbtinsert.c#L1389):
192+
193+
```c
194+
recptr = XLogInsert(RM_BTREE_ID, xlinfo);
195+
```
196+
197+
B-tree operations have [15 distinct WAL record types](https://github.com/postgres/postgres/blob/fc295beb7b74d1f216a53cab61d22027121a763e/src/include/access/nbtxlog.h#L27-L45): inserts, splits, deletes, vacuum, dedup, and more.
198+
199+
More indexes = more WAL = higher pressure on replication, backup and recovery processes.
200+
201+
In loaded systems, too much WAL generated may lead to operational difficulties and even certain critical performance cliffs. At PostgresAI, we automatically detect and often predict such cliffs from WAL and performance patterns, then surface concrete, safe actions (like dropping redundant indexes or tuning autovacuum) before they turn into incidents.
202+
203+
## Summary
204+
205+
Every extra index costs you:
206+
- **Writes:** `INSERT`/`UPDATE` loops through all indexes
207+
- **Reads:** Planner examines all indexes (O(N) to O(N²))
208+
- **Memory:** Unused indexes still consume cache on writes
209+
- **Vacuum:** Processes all indexes twice
210+
- **WAL:** More indexes = more pressure on replication and backups
211+
212+
Unused, redundant indexes and index bloat aren't free — they get modified on every write.
213+
214+
Drop unused indexes. Drop redundant indexes. Reindex degraded (bloated) indexes. And don't forget `CONCURRENTLY` for all these operations.
215+
216+
Doing all of this by hand is possible — but it doesn't scale when you're shipping features every week.
217+
218+
Keep your index set lean.
219+
220+
---
221+
222+
**P.S.** These maintenance tasks are tedious and error-prone when done manually. [PostgresAI](https://postgres.ai) automatically detects unused and redundant indexes, identifies bloat, and safely executes `REINDEX CONCURRENTLY` operations and proposed `DROP INDEX CONCURRENTLY` for approval in PRs/MRs — giving you the performance benefits without the operational overhead, so your APIs stay fast, replication stays healthy, and you don't have to moonlight as a full-time DBA.

src/css/custom.css

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -312,8 +312,12 @@ html[data-theme="dark"] .blog-sec {
312312
}
313313

314314
/* Reduce font size for docusaurus blog */
315-
header h2, header h1 {
316-
font-size: 2rem !important;
315+
header h1 {
316+
font-size: 2.5rem !important; /* 40px */
317+
}
318+
319+
header h2 {
320+
font-size: 1.875rem !important; /* 30px */
317321
}
318322

319323
.products-btn-container {

0 commit comments

Comments
 (0)