Debugging slow SQL queries: an index primer
A slow query is almost always doing more work than it needs to, and EXPLAIN is the tool that tells you exactly where. Learning to read it is worth more than memorizing indexing rules, because the same rule ("add an index") is wrong about a third of the time.
Start with EXPLAIN ANALYZE, not EXPLAIN
EXPLAIN shows the planned query plan; EXPLAIN ANALYZE actually runs the query and shows real timings and row counts alongside the plan. The difference between planned and actual row counts is the single most useful number on the page:
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42 AND status = 'pending';
Seq Scan on orders (cost=0.00..18334.00 rows=12 width=96)
(actual time=0.02..142.31 rows=8 loops=1)
Filter: (customer_id = 42 AND status = 'pending')
Rows Removed by Filter: 499992
That plan says: a sequential scan read 500,000 rows to find 8 matching ones. That's the signature of a missing index — the database has no faster way to find matching rows than reading the whole table.
The fix: a composite index matching the filter
CREATE INDEX idx_orders_customer_status ON orders (customer_id, status);
Re-running EXPLAIN ANALYZE should now show an Index Scan or Bitmap Index Scan instead of Seq Scan, with actual time dropping from milliseconds-times-hundreds-of-thousands to sub-millisecond.
Column order in a composite index matters. An index on (customer_id, status) serves queries filtering on customer_id alone, or on customer_id and status together, but not on status alone — the index is a sorted structure keyed first by customer_id, so without that column pinned down, the database can't jump directly to the relevant status values. Put the column with the highest selectivity (most distinct values relative to row count), or the one always present in your queries, first.
Why the planner sometimes ignores your index anyway
An index existing doesn't guarantee it gets used. Common reasons a query still does a sequential scan despite a matching index:
- Low selectivity: if 60% of rows have
status = 'pending', a sequential scan can be genuinely cheaper than jumping around via an index, since the index scan would still touch most of the table's pages. The planner is often right here. - Function wrapping the column:
WHERE LOWER(email) = 'x@y.com'can't use a plain index onemailbecause the stored values and the search value are transformed differently. Either index the expression directly (CREATE INDEX ON users (LOWER(email))) or store a normalized column. - Implicit type mismatch: comparing a text column to an integer literal, or a
varcharcolumn to a differently-collated literal, can silently disable index usage in some databases. - Leading wildcard LIKE:
WHERE name LIKE '%smith'can't use a standard B-tree index because the index is sorted by prefix;WHERE name LIKE 'smith%'can. Full-text search or trigram indexes (pg_trgmin Postgres) solve the leading-wildcard case. - Stale statistics: after a large bulk load, the planner's row estimates can be badly wrong until
ANALYZEruns. This shows up as a big gap between "rows=X" (estimated) and "actual rows=Y" in the plan.
Reading the shape of the plan, not just the leaf nodes
Plans are trees, read from the innermost/bottom operation outward. A few nodes worth recognizing:
- Nested Loop: fine for small row counts on one side, catastrophic when both sides are large — this is often the top of an accidentally cartesian-ish join.
- Hash Join: good for joining two moderately large sets; needs enough
work_memto build the hash table in memory, or it spills to disk and gets much slower. - Sort: an explicit sort step for an
ORDER BYthat isn't satisfied by an index — if the same sort happens on every request, an index matching theORDER BYcolumns can eliminate it entirely.
Covering indexes: avoiding the table lookup entirely
If a query only ever needs a handful of columns, an index that includes them all lets the database answer straight from the index without touching the table at all (an "index-only scan"):
CREATE INDEX idx_orders_lookup ON orders (customer_id, status) INCLUDE (total_amount, created_at);
This is a meaningful win for hot read paths — dashboards, list endpoints — where the same narrow set of columns is fetched repeatedly.
The indexing patterns that fix most real queries
- Index every foreign key column used in a
JOIN— many ORMs don't do this automatically. - Index columns that appear in
WHERE, in that order of restrictiveness, for composite indexes. - Index columns used in
ORDER BYwhen the same sort runs frequently. - Don't index columns with very low cardinality alone (a boolean
is_deletedcolumn indexed by itself rarely helps) — combine them with a more selective column instead, or use a partial index. - Use a partial index for common filtered queries:
CREATE INDEX ON orders (customer_id) WHERE status = 'pending'is smaller and faster than indexing the whole table when "pending" is a small slice.
The cost of over-indexing
Every index speeds up reads and slows down writes, since each INSERT/UPDATE/DELETE has to maintain every index on the table. A table with fifteen indexes "just in case" is often the actual cause of slow writes that gets misdiagnosed as "the database is slow" generally. Check pg_stat_user_indexes (or your database's equivalent) periodically for indexes with near-zero scans — they're pure write overhead and safe to drop.
A repeatable process
- Run
EXPLAIN ANALYZEon the slow query. - Find the node with the biggest gap between rows expected and rows actually processed, or the single largest actual-time contributor.
- Check whether an existing index should have applied and didn't, or whether no index exists.
- Add the narrowest index that would change that specific node's plan.
- Re-run
EXPLAIN ANALYZEand confirm the plan changed, not just that the query "feels faster."
Skipping step 5 is how databases end up with indexes nobody remembers adding, that never actually fixed anything.