Indexes accumulate. Someone adds one for a report that was retired two
years ago; someone else adds a composite index that makes an older one
redundant; a migration creates one that was never used. Each carries the
write cost measured in lesson X-05, and nothing ever removes them.
Finding unused indexes
Read-only / Safethe query, and the context it needs— Captured on 18.6. Note the second result: without it the first is uninterpretable.
$ psql -U postgres -c "SELECT s.relname AS table_name, s.indexrelname AS index_name, s.idx_scan, pg_size_pretty(pg_relation_size(s.indexrelid)) AS size, i.indisunique, i.indisprimary FROM pg_stat_user_indexes s JOIN pg_index i ON i.indexrelid=s.indexrelid ORDER BY s.idx_scan, pg_relation_size(s.indexrelid) DESC" -c "SELECT stats_reset FROM pg_stat_database WHERE datname=current_database()"
table_name | index_name | idx_scan | size | indisunique | indisprimary
------------+--------------+----------+---------+-------------+--------------
idx6 | idx6_c_idx | 0 | 26 MB | f | f
idx6 | idx6_e_idx | 0 | 10 MB | f | f
idx6 | idx6_a_b_idx | 0 | 5184 kB | f | f
idx6 | idx6_a_idx | 0 | 4824 kB | f | f
...
stats_reset
-------------------------------
2026-08-27 19:38:36.13558+00
Finding redundant indexes
Exact duplicates first:
SELECT indrelid::regclass AS table_name, array_agg(indexrelid::regclass) AS duplicate_indexes, pg_size_pretty(sum(pg_relation_size(indexrelid))) AS total_size FROM pg_index GROUP BY indrelid, indkey, indclass, indexprs, indpredHAVING count(*) > 1;
Dropping safely
-- 1. save the definition, so there is a way backSELECT indexdef FROM pg_indexes WHERE indexname = 'orders_old_idx';-- 2. make it invisible to the planner without dropping itBEGIN;UPDATE pg_index SET indisvalid = false WHERE indexrelid = 'orders_old_idx'::regclass;COMMIT;
Rebuilding a bloated index
Lesson VIII-06 established that a B-tree page is reclaimed only when
completely empty, so scattered deletes leave sparse pages that vacuum
cannot remove. Measure before acting:
CREATE EXTENSION IF NOT EXISTS pgstattuple;SELECT * FROM pgstatindex('orders_pkey');-- avg_leaf_density is the figure; a healthy B-tree sits near 90
Then:
REINDEX INDEX CONCURRENTLY orders_pkey;REINDEX TABLE CONCURRENTLY orders; -- every index on the table
A maintenance routine
Monthly. Review unused indexes, with stats_reset shown alongside.
Review redundant ones with the prefix query. Check for invalid indexes.
Quarterly.pgstatindex on the largest indexes; rebuild any whose
avg_leaf_density has fallen well below 90.
On every schema change. Ask whether the new index makes an existing
one redundant, and whether the column it covers is frequently updated —
which, from lesson VII-02, costs the whole table its HOT updates.
After every pg_upgrade. Statistics reset, so the unused-index
clock starts again. Note the date; the next review is a full business
cycle later.
What to take from this
Report idx_scan with stats_reset. A zero without it is not
evidence.
Check standbys, and never drop an index backing a constraint.
Exact-duplicate detection misses the prefix case, which is the
commonest redundancy.
indisvalid = false is a bounded trial, not a saving — the write
cost remains.
Check for invalid _ccnew indexes after every concurrent build. They
are pure cost and entirely silent.
CLUSTER improves one index’s correlation at the expense of every
other, and is not maintained.
Cross-course references
Ansible for Production Sysadmins — Part XLVIII (Maintenance
windows and rollback) covers running a concurrent rebuild across an
estate, and Part XXXI (Serial execution) covers doing it one host at a
time so a failure is bounded.
Observability for Production Sysadmins — Part LIX (Database
observability) covers alerting on an invalid index, which is the state
a cancelled concurrent build leaves behind and nothing else reports.
Quiz
Knowledge check · 6 questions
Q1. A report lists twelve indexes with idx_scan of zero and recommends dropping all of them. What single additional fact would you require first?
Q2. A duplicate-index query grouping pg_index by indrelid, indkey, indclass, indexprs and indpred returns no rows, yet the schema has an index on (a) and another on (a, b). Why?
Q3. Write latency on a busy table regressed months ago and nobody could account for it. What is worth checking that would not appear in any query plan?
Q4. Which are true of setting indisvalid = false on an index to trial its removal? Select all that apply.
Q5. Running CLUSTER on a table using one index improves that index's correlation while worsening it for every other index on the table.
Q6. Describe a safe process for removing an index you believe is unused.
Passing score: 75%. Answers are checked in this browser.