How to find missing indexes in PostgreSQL
Quick answer: Look in
pg_stat_user_tablesfor large tables (highn_live_tup) whereseq_scanandseq_tup_readare high relative toidx_scan. They are being read end-to-end because a filtered or joined column has no index. A highseq_scancount alone is not the signal; a small table or a “read most rows” query is supposed to scan. Confirm withEXPLAIN(aSeq Scanreturning few rows is the tell) and add the index. Separately, treat indexes withidx_scan = 0as drop candidates to review, never as an automatic drop list.
The signal: sequential scans on a big table
Section titled “The signal: sequential scans on a big table”PostgreSQL tracks how each table is accessed. A table that is constantly scanned in full is usually missing an index:
SELECT relname, seq_scan, seq_tup_read, idx_scan, n_live_tupFROM pg_stat_user_tablesORDER BY seq_scan DESC;| Pattern | What it means |
|---|---|
Large n_live_tup, high seq_scan, low/zero idx_scan | The table is read end-to-end, a filtered/joined column likely needs an index |
High seq_tup_read per seq_scan | Each scan wades through many rows, expensive at scale |
Small table, high seq_scan | Fine, scanning a tiny table is cheaper than an index |
A sequential scan is not automatically bad (small tables and “read most rows” queries should scan). It is a problem when a big table is scanned to return a few rows.
Confirm it with EXPLAIN
Section titled “Confirm it with EXPLAIN”pg_stat_user_tables points you at a table; EXPLAIN proves the query:
EXPLAIN (ANALYZE, BUFFERS)SELECT * FROM orders WHERE customer_id = 42;A Seq Scan on orders that returns one row out of millions is a missing index on
customer_id. See reading a plan
for the details. Remember: foreign-key columns are not indexed automatically. They are the most common miss.
Test the index before you build it (hypopg)
Section titled “Test the index before you build it (hypopg)”Building an index on a big table is expensive, so validate first. Install once
with CREATE EXTENSION hypopg, then create a hypothetical index visible
only to your current session:
SELECT hypopg_create_index('CREATE INDEX ON orders (customer_id)');EXPLAIN SELECT * FROM orders WHERE customer_id = 42; -- does the plan switch to it?If the plan does not change, the real index would not have helped either, you just
saved yourself a pointless build. Hypothetical indexes vanish on disconnect, and
they are session-local, behind a transaction- or statement-mode pooler a
follow-up query may land on a different backend that never saw them. When you do
create for real, use
CREATE INDEX CONCURRENTLY
to avoid blocking writes.
Do not just add, remove the dead weight
Section titled “Do not just add, remove the dead weight”Unused indexes are pure cost: they slow every write and take disk, with no read benefit.
SELECT s.relname, s.indexrelname, s.idx_scan, i.indisvalid, i.indisreplident, i.indisexclusionFROM pg_stat_user_indexes sJOIN pg_index i ON i.indexrelid = s.indexrelidWHERE s.idx_scan = 0 AND i.indisvalid -- exclude failed CREATE INDEX CONCURRENTLY leftovers AND NOT i.indisprimary AND NOT i.indisunique AND NOT i.indisexclusion AND NOT i.indisreplidentORDER BY s.relname;idx_scan = 0 means the index has not been scanned since the last stats reset, a candidate, not a verdict. Rule out indexes backing a primary key, unique, or
exclusion constraint; rare or periodic jobs that have not fired in this window;
and replica-only usage (each server tracks its own stats, so a hot standby index
shows 0 on the primary); invalid indexes left behind by a failed
CREATE INDEX CONCURRENTLY (indisvalid = false), which always read 0 because
the planner will not use them; and any index serving as a table’s replica identity
(indisreplident). After that, drop the rest with DROP INDEX CONCURRENTLY.
The replica-identity case is the one that bites hardest, because the damage is
deferred. DROP INDEX on a REPLICA IDENTITY USING INDEX index succeeds with
no error or warning, the table simply reverts to behaving as
REPLICA IDENTITY NOTHING. But a table in that state that belongs to a
publication replicating UPDATE or DELETE cannot support those operations:
PostgreSQL raises an error on the publisher. So the drop looks clean during the
maintenance window, and writes to that table start failing later, with nothing
obviously connecting the two. Check relreplident on the table before dropping
anything the query returns.
Why does aI-generated code miss indexes?
Section titled “Why does aI-generated code miss indexes?”Your AI coding agent (Claude Code, Cursor) writes the query, but it has no idea
which columns are indexed. That is not in the schema it reads as code, and it
certainly cannot see pg_stat_user_tables. So it writes the natural predicate and
assumes it is fast. Whether a column needs an index is a function of table size,
access pattern, and existing indexes, all runtime facts the model never sees.
How do I keep on top of indexing?
Section titled “How do I keep on top of indexing?”- Review
seq_scanvsidx_scanon your biggest tables periodically. - Add an index for every foreign key you filter or join on.
- Validate candidate indexes with
hypopgbefore building. - Review
idx_scan = 0indexes as drop candidates, after ruling out constraint-backing indexes, rare/periodic queries, replica-only usage, invalid indexes (indisvalid = false), and replica-identity indexes (indisreplident).
How do I know if a table is missing an index?
Weigh it: a highseq_scanalone is not reliable (small tables and most-rows queries should scan). Look for a large table with highseq_scanandseq_tup_readrelative toidx_scan, then confirm withEXPLAINshowing aSeq Scanthat returns few rows.How do I find unused indexes?
Querypg_stat_user_indexesforidx_scan = 0, joined topg_indexso you can filter properly. Treat them as candidates, rule out constraint-backing indexes, rare/periodic queries, replica-only usage, invalid indexes left by a failedCREATE INDEX CONCURRENTLY(indisvalid = false), and any index serving as the table’s replica identity (indisreplident), that last drop succeeds without an error but leaves the table behaving asREPLICA IDENTITY NOTHING, soUPDATEandDELETEon it then error on the publisher if it is published for those actions. ThenDROP INDEX CONCURRENTLYwhat is left.Can I test an index before creating it?
Yes.CREATE EXTENSION hypopg, then create a hypothetical, session-only index soEXPLAINshows whether the planner would use it. They vanish on disconnect and will not survive a transaction/statement-mode pooler hop.Does an index on a foreign key get created automatically?
No. PostgreSQL indexes primary keys and unique constraints, not foreign-key columns, you add those yourself.