Skip to main content
RunBook Academy

PostgreSQLX · Query Planning, Indexes and Performance MethodPlanner

Index maintenance

Intermediate⏱ ~30 minpsql

What you'll learn

  • Identify unused indexes with the context needed to trust the finding
  • Find redundant indexes that exact-duplicate detection misses
  • Drop an index safely, with a way back
  • Rebuild a bloated index without a long lock, and clean up if it fails

Prerequisites

Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27

Not yet marked complete on this device.

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
$ 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, indpred
HAVING count(*) > 1;

Dropping safely

-- 1. save the definition, so there is a way back
SELECT indexdef FROM pg_indexes WHERE indexname = 'orders_old_idx';

-- 2. make it invisible to the planner without dropping it
BEGIN;
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

  1. 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?

  2. 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?

  3. 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?

  4. Q4. Which are true of setting indisvalid = false on an index to trial its removal? Select all that apply.

  5. Q5. Running CLUSTER on a table using one index improves that index's correlation while worsening it for every other index on the table.

  6. Q6. Describe a safe process for removing an index you believe is unused.

Passing score: 75%. Answers are checked in this browser.