Duplicate & Redundant Indexes
Duplicate indexes waste disk, slow writes, and confuse DBAs. Audit catalogs and pg_stat_user_indexes before adding yet another composite on PostgreSQL 18.4.
Search across all documentation pages
Duplicate indexes waste disk, slow writes, and confuse DBAs. Audit catalogs and pg_stat_user_indexes before adding yet another composite on PostgreSQL 18.4.
Quick-reference recipe card - copy-paste ready.
SELECT indexrelid::regclass AS index_name, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
WHERE schemaname = 'public' AND relname = 'orders'
ORDER BY idx_scan;When to reach for this: Table has many indexes, insert latency climbs, or autovacuum cannot keep up.
CREATE TABLE orders (
id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
customer_id bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
-- Redundant: left prefix of composite
CREATE INDEX orders_customer_idx ON orders (customer_id);
CREATE INDEX orders_customer_created_idx ON orders (customer_id, created_at);
-- Duplicate intent (same columns, different name)
CREATE INDEX orders_cust_created_idx ON orders (customer_id, created_at);
SELECT
c.relname AS table_name,
i.relname AS index_name,
pg_get_indexdef(i.oid) AS definition,
s.idx_scan
FROM pg_class c
JOIN pg_index ix ON ix.indrelid = c.oid
JOIN pg_class i ON i.oid = ix.indexrelid
LEFT JOIN pg_stat_user_indexes s ON s.indexrelid = i.oid
WHERE c.relname = 'orders'
ORDER BY pg_get_indexdef(i.oid);Drop orders_customer_idx if all queries needing customer_id also filter or sort on created_at, or keep the single-column index only when prefix-only queries exist and show idx_scan > 0.
What this demonstrates:
pg_get_indexdefidx_scan evidence before dropidx_scan = 0 since stats reset) are drop candidates after observation window.-- Find never-used indexes (since stats reset)
SELECT relname, indexrelid::regclass, idx_scan
FROM pg_stat_user_indexes
WHERE idx_scan = 0 AND schemaname NOT IN ('pg_catalog', 'information_schema');
-- Size ranking
SELECT indexrelid::regclass, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 20;pg_stat_statements and idx_scan.idx_scan zero misleads. Fix: Observe over a business cycle.IF NOT EXISTS.| Alternative | Use When | Don't Use When |
|---|---|---|
| One broad composite | Queries share prefix | Distinct prefix-only paths |
| Partial indexes | Overlapping filters | Too many partials to manage |
| pg_repack after drops | Reclaim bloat space | Immediate space need on drop |
Never drop; constraint depends on it.
Redundant only if no query uses customer_id alone; verify idx_scan.
Yes in production to avoid long write locks.
Keep UNIQUE; drop plain duplicate.
DROP reclaims space; table bloat may remain until vacuum full or repack.
Name constraints consistently; audit pg_indexes after deploy.
FK needs child-side index; may overlap composite if leading column matches.
Partial indexes are not strict prefixes; evaluate separately.
Monthly idx_scan review on top 50 tables by write volume.
REINDEX when bloat remains after dropping duplicates.
Stack versions: This page was written for PostgreSQL 18.4 (stable 18, maintenance 17), pgvector 0.8+, PgBouncer 1.x, Patroni 3.x, and PostGIS 3.5+.
Reviewed by Chris St. John·Last updated Jul 18, 2026