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
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_tup
FROM pg_stat_user_tables
ORDER 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
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)
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
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.indisexclusion
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE 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.indisreplident
ORDER 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?
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?
- 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 DBGorilla helps
DBGorilla connects read-only and gives your AI coding agent (Claude Code, Cursor) the real access statistics, which tables are being sequentially scanned, which foreign keys lack indexes, which indexes look unused, so it can recommend the index a query actually needs and flag drop candidates. It surfaces and explains; it does not touch production. Get started free →
FAQ
How do I know if a table is missing an index?
Weigh it: a high seq_scan alone is not reliable (small tables and most-rows
queries should scan). Look for a large table with high seq_scan and
seq_tup_read relative to idx_scan, then confirm with EXPLAIN showing a
Seq Scan that returns few rows.
How do I find unused indexes?
Query pg_stat_user_indexes for idx_scan = 0, joined to pg_index so you can
filter properly. Treat them as candidates, rule out constraint-backing indexes,
rare/periodic queries, replica-only usage, invalid indexes left by a failed
CREATE 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 as REPLICA IDENTITY NOTHING, so UPDATE
and DELETE on it then error on the publisher if it is published for those
actions. Then DROP INDEX CONCURRENTLY what is left.
Can I test an index before creating it?
Yes.CREATE EXTENSION hypopg, then create a hypothetical, session-only index
so EXPLAIN shows 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.