Indexes & Query Plans
Rule: an index that exists is not an index that is used. A query is fast in development because the table is small, not because the plan is good.
Part of the storage set: storage engines · indexes & query plans (this file) · replication & sharding · session consistency · connection pools & latency
Contents
- The anti-pattern
- Why development hides it
- Why an existing index goes unused
- Composite indexes and the leading-column rule
- N+1 queries
- Reading EXPLAIN
- The cost of an index
- Review checklist
The anti-pattern
-- The index exists.
CREATE INDEX idx_users_email ON users (email);
-- None of these can use it.
SELECT * FROM users WHERE email LIKE '%@b.com'; -- leading wildcard
Each falls back to a sequential scan. The index is present, the query returns correct results, and nothing anywhere reports a problem — the query is simply reading the whole table every time.
Why development hides it
A sequential scan over 200 rows takes under a millisecond. The same scan over 20 million rows takes seconds and saturates disk I/O. The plan is identical; only the row count changed.
Worse, the planner chooses differently at different table sizes. Below a few thousand rows a
sequential scan genuinely is faster than an index lookup, so PostgreSQL correctly ignores your
index in development. You cannot tell "the planner is being sensible about a small table" from
"this index can never be used" by observing behaviour locally. Only EXPLAIN on
production-like data distinguishes them.
This is why a query can pass review, pass CI, pass staging, and take down production on the day a table crosses a threshold nobody was watching.
What an index actually is
Not a row-finding structure — a device for reducing how many pages come off disk. The engine reads fixed-size pages, never individual rows, so the only thing an index changes is page count. See storage-engines.
That reframing explains the behaviours below. A sequential scan on a small table is correct because streaming a few pages beats random single-page reads. A covering index is fast because it answers from index pages and never touches the table. And "the index is not being used" is usually the planner estimating that the index would cost more pages than the scan.
Why an existing index goes unused
A function or expression wraps the column. WHERE lower(email) = ? cannot use an index on
email. Either index the expression — CREATE INDEX ON users (lower(email)) — or store the
normalized value in its own column. The same applies to date(created_at), CAST(...), and
string concatenation.
Implicit type coercion. Comparing a varchar column to an integer parameter, or a bigint
column to a numeric literal, can force a cast on the column side and disable the index. This one
is nearly invisible in review because the SQL looks clean; it shows up in EXPLAIN as a filter
rather than an index condition. Common with IDs sent as strings by a JSON client.
Leading wildcard in LIKE. LIKE 'prefix%' uses a B-tree; LIKE '%suffix' cannot, because a
B-tree is ordered by prefix. For substring or fuzzy search use a trigram index (pg_trgm) or a
real full-text index.
OR across different columns. WHERE a = 1 OR b = 2 often cannot use an index on either.
Rewriting as UNION of two indexed queries is usually dramatically faster.
Low selectivity. An index on a boolean, or on a status column where 95% of rows share one
value, will be ignored for the common value — correctly, since reading most of the table via
random index lookups is slower than scanning it. A partial index fixes exactly this:
CREATE INDEX ON jobs (created_at) WHERE status = 'pending' is small, hot, and always used by the
query that matters.
Stale statistics. The planner chooses from estimates. After a bulk load or a large delete the
estimates can be wildly wrong, producing a good plan for a table shape that no longer exists.
ANALYZE after bulk changes.
NULL semantics. WHERE col != 'x' does not match rows where col IS NULL, and NOT IN
with a NULL anywhere in the list returns no rows at all. Both are correctness bugs before they
are performance bugs.
The planner works from statistics, not data
The chain that decides your plan: statistics → estimated cost → chosen plan. The optimizer never looks at your data. It looks at a statistical summary — row counts, distinct values, most common values, histograms — and picks the cheapest plan according to that summary.
Two consequences worth carrying into review:
- Stale statistics produce confidently wrong plans. After a bulk load, a large delete, or a
sudden distribution change, the summary describes a table that no longer exists. The plan is
optimal for a fiction.
ANALYZEis the fix, and autovacuum's analyze threshold is worth checking on high-churn tables. - Data skew defeats a single plan. With
status95%'completed', one plan cannot be right for bothstatus = 'completed'(scan) andstatus = 'pending'(index). Extended statistics, a partial index on the rare value, or increased histogram resolution (ALTER TABLE ... SET STATISTICS) are the levers. This is also why a query can be fast for months and then pathologically slow for one parameter value.
EXPLAIN shows estimates; EXPLAIN ANALYZE shows both, and the gap between them is the fastest
route to the real problem.
Composite indexes and the leading-column rule
An index on (a, b, c) is sorted by a, then b within equal a, then c. That ordering
determines what it can serve:
| Query predicate | Uses (a, b, c)? |
|---|---|
a = ? | yes |
a = ? AND b = ? | yes |
a = ? AND b = ? AND c = ? | yes, fully |
b = ? | no — b is not the leading column |
a = ? AND c = ? | partially — seeks on a, filters c |
a > ? AND b = ? | seeks on a only; a range stops further seeking |
Two rules follow, and they cover most composite-index mistakes:
- Equality columns first, range columns last. Once a range predicate is used, columns after
it in the index can only filter, not seek.
(status, created_at)servesstatus = 'x' AND created_at > y;(created_at, status)serves it far worse. - A separate index on the leading column is redundant. If
(a, b)exists, an index on(a)alone adds write cost for nothing. Reviewing for redundant indexes is as valuable as reviewing for missing ones.
Covering indexes. If the index contains every column the query needs, the database answers
from the index alone and never touches the table — an index-only scan. INCLUDE (...) in
PostgreSQL adds payload columns without affecting ordering. This is the difference between fast
and very fast on hot queries.
N+1 queries
orders = db.query("SELECT * FROM orders WHERE user_id = ?", user_id) # 1 query
for order in orders:
order.items = db.query("SELECT * FROM items WHERE order_id = ?", order.id) # N queries
Each query is individually fast and correctly indexed. The problem is the count: 50 orders is 51 round trips. At 1 ms of network latency each that is 51 ms; across a network boundary at 5 ms it is a quarter of a second, for a page that should take 10 ms.
This is the most common database performance defect in application code, and ORMs generate it by default through lazy loading — the loop looks like plain attribute access, so nothing in the code reads as a query at all.
Fix by loading in one round trip: an eager-load / JOIN FETCH directive, or fetch the children
for all parent IDs at once (WHERE order_id = ANY(:ids)) and group in memory. Note that eager
loading a to-many association via JOIN multiplies rows — most ORMs handle this with a second
query rather than one join, which is correct and still O(1) round trips.
To catch it: assert on query count in tests for hot endpoints. It is the only reliable way, since N+1 is invisible in code review by construction.
The reason it hurts more than it looks: the cost is round trips, not query complexity. Each query is properly indexed and genuinely fast — the defect is the count, multiplied by network latency. That multiplier is a deployment property, so the same code is fine on localhost and catastrophic across availability zones. See connection-pools-and-latency.
Reading EXPLAIN
Use EXPLAIN (ANALYZE, BUFFERS) — plain EXPLAIN shows estimates only, and estimates are exactly
what is wrong when a plan is bad.
The two things to look at first:
- Estimated vs actual rows.
rows=10withactual rows=45000means the planner is working from a false premise, and every choice above that node is suspect. Usually stale statistics, a correlation the planner cannot see, or a predicate it cannot estimate. loops=Non an inner node. The node's reported time is per loop. A 0.5 ms node withloops=10000is five seconds. This is where N+1-shaped work hides inside a single query.
Then: Seq Scan on a large table with a selective filter means a missing or unusable index.
Filter: ... with a large Rows Removed by Filter means rows are being read and thrown away —
the predicate is not in the index condition. Nested Loop over a large outer set is often a bad
plan from bad estimates.
BUFFERS shows pages read; comparing shared hit to shared read tells you whether a slow query
is slow because of I/O or because of the plan.
The cost of an index
Indexes are not free, and reviews that only ever add them make the system worse:
- Every
INSERT,UPDATE, andDELETEmust maintain every index on the table. A table with twelve indexes has slow writes, and it is usually nobody's job to notice. - Indexes consume storage and cache. An unused index evicts pages a used one needs.
UPDATEs that touch an indexed column cost more; PostgreSQL's HOT optimization only applies when no indexed column changes.
Find unused indexes in pg_stat_user_indexes (idx_scan = 0 over a representative period),
and drop them. Check pg_stat_statements for what actually runs before adding one.
Review checklist
- Every
WHERE,JOIN, andORDER BYcolumn on a large table is indexed, or deliberately not - No function, cast, or expression wraps an indexed column in a predicate
- Parameter types match column types — no implicit coercion on the column side
- Composite indexes lead with equality columns; range predicates come last
- No redundant index that is a prefix of another
- Selective-subset queries use a partial index rather than a low-selectivity full one
- No N+1: loops over a result set do not issue queries; hot paths assert query counts in tests
-
EXPLAIN (ANALYZE, BUFFERS)was run on production-like data, not a dev table - Estimated vs actual rows are close in the plan
- Queries returning many rows are paginated, using keyset pagination rather than large
OFFSET -
ANALYZEruns after bulk loads or large deletes - New indexes are justified against their write cost; unused ones are dropped
Originally written from established practice, then reconciled against TechCraft's Database Internals series, parts P2–P5 and P19 (collection). The index-as-page-reducer framing, the statistics → cost → plan chain, and the data-skew analysis come from that series; the tables, fix ordering, and checklist are this repository's.