Postgres Unused Indexes: Find and Drop Them
Chat2DB TeamIndexes are not free. Every INSERT, and every UPDATE that cannot use a HOT update, has to maintain every index on the table. Each index takes disk space, occupies room in shared_buffers, adds WAL volume, makes VACUUM slower, and gives the planner one more option to consider. An index that no query uses pays all of those costs and returns nothing.
Over the years most databases collect such indexes: an index added for a report that was later retired, a second index created by a migration that did not notice an existing one, or a multi-column index that makes a single-column one redundant. This guide shows how to find postgres unused indexes with pg_stat_user_indexes, how to exclude indexes you must keep, why statistics resets and replicas can mislead you, how to detect duplicate and overlapping indexes, and how to drop unused index postgres objects safely with DROP INDEX CONCURRENTLY.
Where index usage is recorded
PostgreSQL's cumulative statistics system counts how often each index is used. The view for user tables is pg_stat_user_indexes:
SELECT schemaname,
relname AS table_name,
indexrelname AS index_name,
idx_scan,
idx_tup_read,
idx_tup_fetch
FROM pg_stat_user_indexes
ORDER BY idx_scan
LIMIT 5;The columns that matter:
idx_scan: number of index scans initiated on this index. Plain index scans, index-only scans and bitmap index scans all count.idx_tup_read: index entries returned by scans of this index.idx_tup_fetch: live table rows fetched by simple index scans using this index.last_idx_scan(PostgreSQL 16 and later): the time of the last scan of this index, based on the time the transaction that did it ended.
An index with idx_scan = 0 has not been used for a scan since statistics were last reset. That last clause is where most mistakes come from, so we will return to it.
Step 1: list candidate unused indexes
Not every index with zero scans can be dropped. Unique indexes and primary keys enforce constraints even if no query ever reads through them, and exclusion constraints work the same way. Indexes that back a constraint cannot be dropped with DROP INDEX at all. The query below joins pg_stat_user_indexes with pg_index and pg_constraint to exclude all of them:
SELECT s.schemaname,
s.relname AS table_name,
s.indexrelname AS index_name,
s.idx_scan,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
pg_relation_size(s.indexrelid) AS index_bytes
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique
AND NOT i.indisprimary
AND NOT i.indisexclusion
AND i.indisvalid
AND NOT EXISTS (
SELECT 1
FROM pg_constraint c
WHERE c.conindid = s.indexrelid
)
ORDER BY pg_relation_size(s.indexrelid) DESC;Example output:
schemaname | table_name | index_name | idx_scan | index_size | index_bytes
------------+------------+---------------------------+----------+------------+-------------
public | orders | orders_status_created_idx | 0 | 214 MB | 224395264
public | events | events_legacy_type_idx | 0 | 88 MB | 92274688
public | users | users_last_login_idx | 0 | 12 MB | 12582912The names and sizes above are illustrative. What each filter does:
idx_scan = 0: no scans recorded in the current statistics window.NOT i.indisunique,NOT i.indisprimary,NOT i.indisexclusion: keep uniqueness, primary key and exclusion enforcement.i.indisvalid: skip invalid indexes, such as leftovers from a failedCREATE INDEX CONCURRENTLY. Those deserve separate attention because they are maintained on writes but never used.- The
pg_constraint.conindidcheck: skip any index a constraint depends on, including one used by a foreign key that references it.
Sorting by size puts the biggest wins first. On PostgreSQL 16 or later, add s.last_idx_scan to the select list and consider indexes that have not been scanned for months, not just those with exactly zero scans.
Finding invalid indexes
While you are here, list invalid indexes too:
SELECT indexrelid::regclass AS index_name,
indrelid::regclass AS table_name
FROM pg_index
WHERE NOT indisvalid;An invalid index left behind by a failed concurrent build should be dropped and, if still needed, rebuilt.
Step 2: make sure the statistics window is long enough
idx_scan = 0 only means "not used since the counters were last reset". If the counters were reset yesterday, a monthly billing job or a quarterly report may not have run yet.
When were stats last reset?
SELECT datname, stats_reset
FROM pg_stat_database
WHERE datname = current_database(); datname | stats_reset
---------+-------------------------------
appdb | 2026-06-02 03:14:07.51843+00A NULL value means the database-wide counters have not been reset since the statistics were created. Your timestamp will differ.
What resets the counters
pg_stat_reset()resets all statistics counters for the current database, including index usage. Anyone troubleshooting with it also erases your unused-index evidence.pg_stat_reset_single_table_counters(oid)resets counters for one table or index. After such a targeted reset, that object's history can be shorter than you would assume from looking only at the rest of the database, so ask whether anyone has used it.- Crash recovery: cumulative statistics are saved on a clean shutdown and discarded when the server goes through crash recovery, including an immediate shutdown.
- Major version upgrades:
pg_upgradedoes not carry the cumulative activity counters over to the new cluster. - Recreated indexes:
REINDEX CONCURRENTLYand drop-and-recreate produce a new index with fresh counters.
A practical rule: only trust idx_scan = 0 once the window covers at least one full business cycle, including month-end and any quarterly or yearly jobs you know about.
Snapshot the counters instead of resetting
To measure usage over a defined period without resetting anything, save snapshots and compare them:
CREATE TABLE IF NOT EXISTS index_usage_snapshot (
taken_at timestamptz NOT NULL DEFAULT now(),
indexrelid oid NOT NULL,
index_name text NOT NULL,
idx_scan bigint NOT NULL
);
INSERT INTO index_usage_snapshot (indexrelid, index_name, idx_scan)
SELECT indexrelid, schemaname || '.' || indexrelname, idx_scan
FROM pg_stat_user_indexes;Run the insert on a schedule, then compare the first and latest snapshots:
SELECT cur.index_name,
cur.idx_scan - old.idx_scan AS scans_in_window
FROM index_usage_snapshot cur
JOIN index_usage_snapshot old USING (indexrelid)
WHERE cur.taken_at = (SELECT max(taken_at) FROM index_usage_snapshot)
AND old.taken_at = (SELECT min(taken_at) FROM index_usage_snapshot)
ORDER BY scans_in_window, cur.index_name;A negative difference means the counters were reset between the snapshots, and the window for that index is not reliable.
Step 3: check every replica
This is the most common way to drop an index that is in use. Statistics are local to each server. On a streaming replication setup, queries executed on a hot standby increment idx_scan only on that standby. The primary's pg_stat_user_indexes knows nothing about them.
If your read replicas serve reporting, search or API reads, an index can show idx_scan = 0 on the primary while being essential on a replica. Because the physical standby is a copy of the primary, dropping the index on the primary drops it on every standby too.
So run the candidate query on the primary and every standby, and only treat an index as unused if it has zero (or negligible) scans everywhere. Also check each standby's own reset history, since a standby that was rebuilt or restarted after a crash starts with a fresh statistics window. A client such as Chat2DB (opens in a new tab) that keeps connections to the primary and replicas side by side makes it quick to run the same query on each node and compare the results.
Step 4: look for duplicate and overlapping indexes
Unused indexes are one kind of waste; indexes that duplicate each other are another. A duplicate may show scans simply because the planner picks one of two identical options, so the zero-scan query will not find it.
Exact duplicates
Two indexes are exact duplicates if they are on the same table with the same columns or expressions, the same operator classes and collations, and the same predicate:
SELECT indrelid::regclass AS table_name,
array_agg(indexrelid::regclass ORDER BY indexrelid) AS duplicate_indexes,
pg_size_pretty(sum(pg_relation_size(indexrelid))) AS total_size
FROM pg_index
GROUP BY indrelid,
indkey::text,
indclass::text,
indcollation::text,
coalesce(indexprs::text, ''),
coalesce(indpred::text, '')
HAVING count(*) > 1
ORDER BY sum(pg_relation_size(indexrelid)) DESC;From each group, keep one index, preferring the one that backs a constraint or is unique. If one is unique and the other is not, the non-unique one is the redundant copy. The access method is not part of the grouping above, but operator classes are specific to an access method, so indexes of different types do not end up in the same group.
Overlapping (left-prefix) indexes
A B-tree index on (customer_id, created_at) can serve most queries that an index on (customer_id) alone would serve, because the leading column is the same. The single-column index is often redundant:
SELECT a.indrelid::regclass AS table_name,
a.indexrelid::regclass AS covered_index,
b.indexrelid::regclass AS covering_index,
pg_size_pretty(pg_relation_size(a.indexrelid)) AS covered_size
FROM pg_index a
JOIN pg_index b
ON a.indrelid = b.indrelid
AND a.indexrelid <> b.indexrelid
JOIN pg_class ca ON ca.oid = a.indexrelid
JOIN pg_class cb ON cb.oid = b.indexrelid
JOIN pg_am am ON am.oid = ca.relam
WHERE am.amname = 'btree'
AND cb.relam = ca.relam
AND b.indkey::text LIKE a.indkey::text || ' %'
AND a.indexprs IS NULL AND b.indexprs IS NULL
AND a.indpred IS NULL AND b.indpred IS NULL
AND NOT a.indisunique
ORDER BY pg_relation_size(a.indexrelid) DESC;indkey holds the column numbers as a space-separated list, so '3 7' is a prefix of '3 7 2'. Treat the result as a review list rather than a drop list:
- The shorter index is smaller, so scans on it read fewer pages. For very hot queries this can matter.
- The query does not compare operator classes or collations. Verify them with
pg_get_indexdef()before deciding. - Columns in an
INCLUDEclause are also stored inindkey, so check the definitions of any covering indexes found this way.
SELECT pg_get_indexdef('orders_customer_id_idx'::regclass);Step 5: know the non-obvious reasons to keep an index
Before dropping a zero-scan index, rule out these cases:
- Expression index statistics.
ANALYZEcollects statistics on the expressions of expression indexes, and the planner uses them for row estimates. An index onlower(email)can improve estimates forWHERE lower(email) = ...even when the plan never scans it. Dropping it can change plans elsewhere. On PostgreSQL 14 and later, extended statistics on expressions (CREATE STATISTICS) can replace this role. - Rare but critical jobs. Year-end closing, disaster recovery scripts, or an admin tool used during incidents.
- Recently created indexes. An index built last week for an upcoming feature has had no chance to be used.
- Workload on another server, as covered in the replica section.
Step 6: test the impact before dropping
PostgreSQL does not have a native way to make an index invisible to the planner while keeping it maintained. There is no ALTER INDEX ... INVISIBLE as in some other databases. That leaves a few options:
Test inside a rolled-back transaction
DROP INDEX is transactional, so you can see what plans would look like without the index:
BEGIN;
DROP INDEX orders_status_created_idx;
EXPLAIN SELECT * FROM orders WHERE status = 'pending' ORDER BY created_at LIMIT 50;
ROLLBACK;The catch is locking: DROP INDEX takes an ACCESS EXCLUSIVE lock on the table, held until ROLLBACK. Every other query on orders waits in the meantime. Only do this on a copy of the database, or on production with a short lock_timeout and a transaction that lasts a second or two.
Use HypoPG
The HypoPG extension works with hypothetical indexes for the planner, and recent versions can also hide an existing index for the current session with hypopg_hide_index(), which lets you run EXPLAIN as if it did not exist without locking the table. See the HypoPG guide for installation and usage. It is a third-party extension, so check that it is available on your platform.
Avoid catalog hacks
You may see advice to set pg_index.indisvalid to false by hand. Updating system catalogs directly is unsupported and can leave the index in an inconsistent state. Do not rely on it.
Step 7: drop the index safely
Save the definition first
SELECT format('%s;', pg_get_indexdef(indexrelid)) AS recreate_sql
FROM pg_index
WHERE indexrelid = 'public.orders_status_created_idx'::regclass; recreate_sql
---------------------------------------------------------------------------------------------
CREATE INDEX orders_status_created_idx ON public.orders USING btree (status, created_at);Keep that statement in your migration or runbook, so rolling back is one command (use CREATE INDEX CONCURRENTLY when you recreate it on a busy table).
DROP INDEX CONCURRENTLY
A plain DROP INDEX takes an ACCESS EXCLUSIVE lock on the table. On a busy table, even a brief wait for that lock can queue all traffic behind it. DROP INDEX CONCURRENTLY avoids blocking reads and writes:
SET lock_timeout = '5s';
DROP INDEX CONCURRENTLY IF EXISTS public.orders_status_created_idx;Its rules:
- It cannot run inside a transaction block, so it must be its own statement outside
BEGIN ... COMMIT. Many migration tools need a flag to disable their wrapping transaction. - It drops exactly one index per statement and does not support
CASCADE. - It waits for transactions that might be using the index to finish, so a long-running transaction delays it.
- It cannot drop an index that backs a constraint; drop the constraint with
ALTER TABLE ... DROP CONSTRAINTinstead. - If it fails partway, the index can be left in an invalid state. Run the same
DROP INDEX CONCURRENTLYagain to finish the job.
Watch after the drop
After dropping, watch the slow query log or pg_stat_statements for queries on the affected table whose mean time increased. Keep the recreate statement at hand for a while, at least one more business cycle.
Checklist
- Query
pg_stat_user_indexesforidx_scan = 0, excluding unique, primary key, exclusion and constraint-backing indexes. - Check
pg_stat_database.stats_resetand remember crashes, upgrades and single-object resets. - Run the same query on every standby.
- On PostgreSQL 16 or later, use
last_idx_scanto see when each index was last used. - Find exact duplicates and left-prefix overlaps with
pg_index. - Rule out expression-index statistics and rare jobs.
- Test with
EXPLAINon a copy or with HypoPG. - Save
pg_get_indexdef(), thenDROP INDEX CONCURRENTLYwith alock_timeout.
Summary
Unused indexes slow down writes, bloat storage and waste cache. pg_stat_user_indexes shows which indexes have not been scanned, but only since the last statistics reset and only on the server you are connected to. Filter out indexes that enforce constraints, confirm the statistics window, check every replica, look for duplicate and overlapping indexes, and test plans before removing anything. When you are sure, save the definition and drop unused index postgres objects with DROP INDEX CONCURRENTLY so the cleanup never blocks production traffic.
