How do you find PostgreSQL unused indexes with pg_stat_user_indexes?
An unused index is one the planner never chooses for any query. A low-value index is one it chooses so rarely, or for such a small table, that a sequential scan or another index would serve almost as well. Both still cost you on every write. Each INSERT, and each UPDATE that cannot use a heap-only tuple, must add an entry to every index on the table. Vacuum has to scan each index to remove dead entries. The index pages compete with hot table data for shared buffers and the OS page cache. Every base backup, replica rebuild, and restore also copies the index bytes. On a write-heavy table with a dozen indexes, an index nobody reads still adds to all of these costs.
The audit query
Start from pg_stat_user_indexes, which tracks idx_scan for each index. Join it to pg_index on indexrelid to get indisunique and indisprimary, so constraint-backing indexes are flagged rather than hidden. Join it to pg_class for the table and index names. Compute pg_relation_size(indexrelid) for the on-disk footprint. Sort by idx_scan ascending, then by size descending. Large indexes with few or no scans then appear first, which is where cleanup pays off most.
Read idx_scan = 0 as a lead to investigate, not a verdict. The count only covers the period since statistics were last reset. It is also local to the server you query.
Who should run it
This audit belongs to the backend and platform engineers who own the schema. It matters most on write-heavy databases, where index maintenance limits throughput, and on storage-constrained ones, where index size drives disk, backup, and replica costs.
-- Since when has idx_scan been counting on this server?
-- A recent reset makes zero scans meaningless.
SELECT datname, stats_reset
FROM pg_stat_database
WHERE datname = current_database();
-- Index usage audit: rarely scanned, large indexes first.
SELECT
s.schemaname,
t.relname AS table_name,
i.relname AS index_name,
s.idx_scan, -- scans since last stats reset, this server only
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
x.indisunique, -- backs a UNIQUE constraint or index
x.indisprimary -- backs the PRIMARY KEY
FROM pg_stat_user_indexes AS s
JOIN pg_index AS x ON x.indexrelid = s.indexrelid
JOIN pg_class AS i ON i.oid = s.indexrelid -- index name
JOIN pg_class AS t ON t.oid = s.relid -- table name
ORDER BY
s.idx_scan ASC, -- least used first
pg_relation_size(s.indexrelid) DESC -- then biggest footprint
LIMIT 50;
-- Treat idx_scan = 0 as a lead, not a verdict. Unique and primary
-- indexes enforce constraints even when no query scans them.
-- Run this on every replica as well, because counters are per server.
Lists each user index with its scan count, on-disk size, and unique and primary key flags, sorted so large, rarely scanned indexes appear first. A preliminary query shows when the statistics were last reset.
Can you trust idx_scan? Checking the stats window and every replica
idx_scan is a cumulative counter. It shows how many times the planner chose the index since statistics were last reset, not across the index's whole life. Before trusting a zero, check when counting started. The stats_reset column in pg_stat_database gives the timestamp for the database.
Several events can wipe or replace that history:
pg_stat_reset(): someone ran it, often while debugging, and every counter in the database went back to zero.- Crash recovery: after a crash or immediate shutdown, PostgreSQL discards the statistics. A clean shutdown preserves them.
- Failover: a promoted replica has only its own counters, which reflect the reads it served as a standby. It does not have the old primary's write-path and query history.
A recent reset leaves a short window, and a short window hides infrequent workloads. Monthly invoicing, quarterly reports, and year-end close may each depend on an index that shows zero scans today.
To get a long window, snapshot pg_stat_user_indexes into a table on a schedule, such as daily. Record a timestamp and the server name with each row. Judge usage by the change in idx_scan between snapshots, and treat a drop in the counter as a sign of a reset. Wait until the history spans a full business cycle before calling any index unused.
Counters are also tracked separately on each server:
+------------------+
| Primary |
| idx_scan = 0 |
+--------+---------+
| WAL streaming
+-----------+-----------+
v v
+------------------+ +------------------+
| Replica A | | Replica B |
| idx_scan = 48210 | | idx_scan = 0 |
+------------------+ +------------------+
A dropped index is gone from every replica, because the drop replicates. Run the usage query on the primary and on every replica that takes reads, and sum the results before you decide.
-- History table. Keep it on the primary: replicas are read-only.
CREATE TABLE IF NOT EXISTS index_usage_snapshot (
captured_at timestamptz NOT NULL,
server_name text NOT NULL,
is_replica boolean NOT NULL,
stats_reset timestamptz, -- when this server's counters last started
schemaname name NOT NULL,
relname name NOT NULL,
indexrelname name NOT NULL,
idx_scan bigint NOT NULL
);
-- Run daily on the primary and on every replica that serves reads.
-- On the primary, insert directly. On a replica, run only the SELECT and
-- load its output into the primary's table, for example with psql \copy.
INSERT INTO index_usage_snapshot
SELECT now(),
coalesce(nullif(current_setting('cluster_name'), ''),
host(inet_server_addr()), 'local'),
pg_is_in_recovery(),
(SELECT stats_reset FROM pg_stat_database WHERE datname = current_database()),
s.schemaname, s.relname, s.indexrelname, s.idx_scan
FROM pg_stat_user_indexes s;
-- Scans per index per server over the whole history. A counter that went
-- down means a reset, so only the post-reset value counts for that interval.
SELECT server_name, schemaname, indexrelname,
min(captured_at) AS first_seen, max(captured_at) AS last_seen,
sum(CASE WHEN prev IS NULL THEN 0
WHEN idx_scan < prev THEN idx_scan -- reset detected
ELSE idx_scan - prev END) AS scans_in_window,
count(*) FILTER (WHERE idx_scan < prev) AS resets_seen
FROM (SELECT *, lag(idx_scan) OVER (PARTITION BY server_name, schemaname, indexrelname
ORDER BY captured_at) AS prev
FROM index_usage_snapshot) d
GROUP BY server_name, schemaname, indexrelname
ORDER BY indexrelname, server_name;
This example stores daily snapshots of pg_stat_user_indexes from each server in a history table on the primary, then adds up the change in idx_scan per server and treats any drop in the counter as a stats reset.
Which indexes should you never drop even with zero scans?
A unique index enforces its constraint on every INSERT and on every UPDATE that touches the key. Those checks do not count as scans. A primary key, a unique constraint or an exclusion constraint can show idx_scan = 0 and still be the only thing that prevents duplicate or overlapping rows. If you drop it, you lose data integrity silently. Nothing errors until bad data arrives.
Foreign keys need a closer look. PostgreSQL indexes the referenced side of a foreign key, because that side must be unique. It does not index the referencing columns. When a row in the parent table is deleted, or its key is updated, PostgreSQL checks the child table for matching rows. Without an index on the child's foreign key columns, that check is a sequential scan of the child table, run once per affected parent row. A low scan count here may mean only that no parent deletes ran during the stats window. A monthly purge job would bring the problem back.
Filter query
Before you review candidates, exclude these indexes:
- Join
pg_indexto your candidate list and drop rows whereindisprimaryorindisuniqueis true. - Left join
pg_constraintonconindid = indexrelidand drop any index that backs a constraint. This also covers exclusion constraints. - For foreign keys (
contype = 'f'), compareconkeyagainst each candidate's leading columns inindkeyon the referencing table. An index whose leading columns match a foreign key should stay unless you have checked its delete patterns.
What the stats don't tell you
Usage stats show that an index was scanned. They don't show why the scan mattered. A lookup run twice a year during incident response, or a quarterly finance report, can depend on an index that looks idle. Before you drop one, ask the people who own those queries.
-- Zero-scan indexes with constraint-backed ones removed,
-- and foreign key coverage flagged for manual review.
SELECT s.schemaname,
s.relname AS table_name,
s.indexrelname AS index_name,
pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size,
-- true if the index's leading columns cover a foreign key on this table
EXISTS (
SELECT 1
FROM pg_constraint fk
WHERE fk.contype = 'f'
AND fk.conrelid = i.indrelid
AND fk.conkey <@ ARRAY(
SELECT u.k
FROM unnest(i.indkey::smallint[]) WITH ORDINALITY AS u(k, ord)
WHERE u.ord <= array_length(fk.conkey, 1))
) AS backs_fk_lookup
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
-- Any constraint that owns this index: PK, UNIQUE, EXCLUDE
LEFT JOIN pg_constraint c ON c.conindid = s.indexrelid
AND c.contype IN ('p', 'u', 'x')
WHERE s.idx_scan = 0
AND NOT i.indisprimary -- enforces the primary key
AND NOT i.indisunique -- enforces uniqueness on every write
AND c.oid IS NULL -- not owned by a constraint
ORDER BY backs_fk_lookup DESC, pg_relation_size(s.indexrelid) DESC;
Lists zero-scan indexes after excluding primary key, unique and exclusion constraint indexes, and flags indexes whose leading columns cover a foreign key so you check delete and update patterns on the parent table before dropping them.
How do you detect duplicate and overlapping indexes?
A zero-scan index is one kind of waste. Another is two indexes doing the same job, where the planner uses only one and both slow down every write. To find these, query pg_index and group by indrelid and the key columns in indkey. Indexes with identical key lists are candidates for being exact duplicates. Next, look for prefix overlap: a B-tree on (a) is usually redundant next to one on (a, b), because the planner can use the leading column of the wider index for lookups on a alone. The wider index is larger, so those lookups may cost slightly more. Usually the saving on writes outweighs that.
Matching column lists are not proof of redundancy. Before you treat two indexes as interchangeable, also compare indclass, indpred, indexprs, indnkeyatts and the access method.
| Difference | Why the overlap may not be real | Redundant? |
|---|---|---|
Operator class (e.g. text_pattern_ops) |
Supports different operators, such as LIKE 'x%' under a non-C collation |
No |
| Partial-index predicate | Covers a different subset of rows | Only if predicates match |
INCLUDE columns |
Enables index-only scans the other index can't provide | Compare key columns only, then check what the queries need |
| Unique constraint | Enforces a rule; (a, b) does not enforce uniqueness on a |
No |
| Index type (GIN, GiST, BRIN vs B-tree) | Serves different query shapes | No |
Dropping is not the only option. Several overlapping indexes can often be replaced by a single one, such as (a, b) INCLUDE (c). Build the replacement first, confirm that the planner uses it, then remove the old indexes.
-- Find B-tree indexes whose key columns equal, or are a leading prefix of,
-- another index on the same table. Read-only: review each pair before dropping.
WITH idx AS (
SELECT i.indexrelid, i.indrelid, i.indisunique, i.indisprimary, am.amname,
-- key columns only; INCLUDE columns sit past indnkeyatts
(string_to_array(i.indkey::text, ' ')::int2[])[1:i.indnkeyatts] AS keycols,
(string_to_array(i.indclass::text, ' ')::oid[])[1:i.indnkeyatts] AS opclasses,
pg_get_expr(i.indpred, i.indrelid) AS pred, -- partial-index WHERE
pg_get_expr(i.indexprs, i.indrelid) AS exprs -- expression keys
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
)
SELECT a.indrelid::regclass AS table_name,
a.indexrelid::regclass AS candidate,
b.indexrelid::regclass AS covered_by,
CASE WHEN a.keycols = b.keycols THEN 'same keys' ELSE 'prefix' END AS overlap,
a.indisunique OR a.indisprimary AS candidate_enforces_constraint,
pg_size_pretty(pg_relation_size(a.indexrelid)) AS candidate_size
FROM idx a
JOIN idx b ON b.indrelid = a.indrelid AND b.indexrelid <> a.indexrelid
WHERE a.amname = 'btree' AND b.amname = 'btree' -- different AMs are not redundant
AND a.keycols = b.keycols[1:cardinality(a.keycols)] -- leading prefix or equal
AND a.opclasses = b.opclasses[1:cardinality(a.opclasses)] -- e.g. text_pattern_ops differs
AND a.pred IS NOT DISTINCT FROM b.pred -- same row subset
AND a.exprs IS NOT DISTINCT FROM b.exprs
-- list each exact duplicate pair once
AND (a.keycols <> b.keycols OR a.indexrelid < b.indexrelid)
ORDER BY 1, 2;
-- Rows where candidate_enforces_constraint is true are NOT safe to drop:
-- a unique index on (a) enforces a rule that (a, b) does not.
-- For 'same keys' rows, also compare INCLUDE columns (pg_get_indexdef) before choosing.
A read-only catalog query that pairs B-tree indexes with identical or leading-prefix key columns, filtering out pairs that differ in operator class, partial predicate, expressions or access method, and flagging indexes that back a unique or primary key constraint.
What causes index bloat and when does it actually matter?
Every non-HOT update writes a new index entry pointing at the new row version, and the old entry stays until vacuum confirms that no snapshot can still see it. Deletes leave dead entries the same way. When inserts hit a full B-tree leaf page, the page splits into two roughly half-full pages. Under heavy churn, many leaves end up sparsely populated. Vacuum removes dead entries and can recycle pages that become completely empty, so the space is reused. It almost never returns space to the filesystem, though, so the index file stays at its high-water mark.
| Method | Cost | Accuracy | Use it for |
|---|---|---|---|
Catalog-based estimate (pg_class and pg_stats) |
Reads only catalogs and statistics | Approximate. Stale statistics or wide, variable-length keys can make it badly wrong | Screening every index in a database |
pgstattuple's pgstatindex |
Reads the whole index, which adds I/O | Measures leaf density and fragmentation directly | Confirming a candidate before acting |
Bloat is worth acting on when it has a measurable cost. Each index page that is mostly empty still takes a buffer in shared memory and the OS page cache, which pushes out hotter data. Range scans read more pages to return the same rows. Physical backups and new replicas copy the full file, including the empty space.
A rebuild alone gives only a temporary improvement if the workload regrows the bloat. Fix the cause as well:
- Fillfactor on the table: a lower fillfactor leaves room on each heap page, which allows more HOT updates. HOT updates skip index writes entirely.
- Fillfactor on the index: a lower index fillfactor reduces page splits on indexes that receive inserts throughout the key range.
- Autovacuum: per-table scale factors and thresholds make vacuum run on hot tables before dead entries accumulate.
- Update and delete patterns: avoid rewriting indexed columns that have not changed. For bulk deletes of old data, partition by time and drop whole partitions instead.
How do you drop or rebuild an index without blocking production?
Before touching anything, record the index definition with pg_get_indexdef('index_name'::regclass) and store it with your change ticket. If a query regresses after the drop, you can rebuild the index exactly as it was instead of reconstructing it from memory.
DROP INDEX CONCURRENTLY waits for in-flight queries to stop using the index instead of taking an exclusive lock on the table. It cannot run inside a transaction block, so migration tools that wrap every step in BEGIN need an exception for it. If it fails partway, the index can be left invalid, and you must run the drop again.
To rebuild a bloated index, REINDEX CONCURRENTLY is the simplest option. It keeps the index name and any constraint attached to it. The manual alternative is CREATE INDEX CONCURRENTLY under a new name, followed by a concurrent drop of the old index and a rename. This gives you control over each step, but it is awkward for indexes that back primary keys or unique constraints.
Both concurrent rebuild methods have costs:
- Disk space: a full second copy of the index exists during the build.
- Runtime: the build scans the table more than once.
- Long transactions: the build waits for every older transaction to finish.
- Invalid leftovers: a failed build leaves an index behind. Find these with
pg_index.indisvalid = falseand drop them.
Set lock_timeout (for example SET lock_timeout = '5s') before any DDL. A DDL statement waiting for a lock also blocks every query queued behind it. With a timeout, the statement fails quickly instead. Script your retries with backoff.
| Operation | Blocks writes | Blocks reads | In transaction block | On failure |
|---|---|---|---|---|
DROP INDEX |
Yes | Yes | Allowed | Rolls back |
DROP INDEX CONCURRENTLY |
No | No | Not allowed | May leave invalid index |
REINDEX |
Yes | Queries using the index | Allowed | Rolls back |
REINDEX CONCURRENTLY |
No | No | Not allowed | Leaves invalid copy |
CREATE INDEX |
Yes | No | Allowed | Rolls back |
CREATE INDEX CONCURRENTLY |
No | No | Not allowed | Leaves invalid index |
When should you avoid cleaning up indexes, and what does it save?
The checklist
- Snapshot
pg_stat_user_indexeswith a timestamp so later readings can be compared against a baseline. - Confirm the stats window covers month-end jobs, reports, and other rare workloads.
- Check
idx_scanon every replica, because the primary's counters don't include reads served there. - Exclude indexes that back primary key, unique, or exclusion constraints.
- Save each definition with
pg_get_indexdef(). The saved definition is your recreate script. - Drop one index at a time with
DROP INDEX CONCURRENTLY. - Watch p95/p99 latency and the slow-query log for at least one full business cycle before dropping the next index.
When to hold off
Postpone the cleanup in any of these cases:
- The stats window is short. A recent reset leaves too little history to show what is actually unused.
- Volumes are nearly full. A rebuild or rollback needs room for a second copy of the index.
- You're in peak traffic.
- There is no tested recreate plan.
Rollback is slow. Recreating an index on a large table with CREATE INDEX CONCURRENTLY takes time and I/O, and any regression the drop caused lasts until that build finishes.
Is it worth it?
Illustrative estimate (assumed numbers, not a benchmark). Assume these inputs:
- 40 GB of unused indexes
- 3 copies (primary plus two replicas)
- Storage at $0.10 per GB-month
- 8 engineering hours at $120 per hour
The calculations:
- Monthly savings = size × copies × price = 40 × 3 × 0.10 = $12
- One-time cost = 8 × 120 = $960
- Payback = 960 / 12 = 80 months
Storage alone rarely justifies the effort. The stronger reasons to drop unused indexes are reduced write amplification, faster vacuum, and less WAL.
The checklist also brings the cost down. A scripted, repeatable run turns the next cleanup into an hour of review instead of a day of rushed investigation. It also leaves a record of what was dropped and how to restore it.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to postgresql unused indexes, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- The dependency graph answers "what does this touch?" in seconds instead of a manual audit.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- You can size a change's blast radius before merging, avoiding surprise cross-team firefighting.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.