PostgreSQL Not Using an Index? Diagnose It with EXPLAIN
Diagnose PostgreSQL sequential scans with runnable EXPLAIN examples. Test selectivity, statistics and expression indexes in a disposable browser VM.
You created an index, reran a query, and PostgreSQL still chose Seq Scan. Before changing planner settings, check whether the index can answer the condition and whether using it would actually save work.
An index is an available access path, not a command to use it. PostgreSQL compares estimated costs. Reading most of a table in order can cost less than following index entries and then visiting the matching table pages. Conversely, a highly selective condition often makes an index worthwhile.
The VM gives you a controlled example: 20,000 events, 200 marked open, and 19,800 marked closed. A B-tree index exists on state; a padding column makes each row large enough for page access to matter. The data is synthetic. It demonstrates planner choices, not a production performance benchmark.
The same index is available in both queries. What changes is how much of the table the query needs. Even the 1% case can touch many pages when matching rows are spread out; inspect the plan and buffers rather than treating selectivity as a fixed threshold.
Use Run all steps for each example. EXPLAIN (ANALYZE, BUFFERS) executes these SELECT queries and reports actual row counts and buffer activity. Plan timings inside an emulated browser VM should not be used to predict native server latency. The useful comparison here is the access path and the number of rows it processes.
Ask the index for a small part of the table
Start with the rare value. The index can directly locate entries whose state equals 'open'. On this prepared dataset you should see events_state_idx, usually through an index scan or a bitmap scan. Both are legitimate uses of the index; do not search only for the exact phrase Index Scan.
Read the plan from the node that accesses events. Compare estimated rows with actual rows. The result should contain 200 events, so an estimate in that neighborhood is plausible. A large mismatch would point toward a statistics or data-distribution question before a planner-configuration question.
The separate count query confirms the dataset. EXPLAIN's cost figures are planner units, not milliseconds. The actual timing fields are measurements of this execution, with instrumentation overhead and this VM's cache state. BUFFERS counts accesses to shared buffers; a buffer hit means the page was already in PostgreSQL's shared cache, not that the whole query was free.
From the shell, open the demo database (PGDATABASE=blog_lab):
psqlThen run this at the SQL prompt:
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE state = 'open';SELECT count(*) AS open_events FROM events WHERE state = 'open';Expected: The plan names events_state_idx; open_events is 200.
Make the same index an unattractive choice
Change only the requested value to 'closed'. This time almost every row qualifies. An index scan would still have to reach nearly the entire table. On this dataset the planner should prefer a sequential scan.
That is not evidence that the index is broken. It is evidence that the query asks for a different fraction of the data. Selecting only a few columns does not automatically make an index-only scan available either: this index contains state and tuple references, but not the selected ID value.
If your real query has a bad estimate, run ANALYZE after a substantial load or change and compare the plan again. Statistics help the planner estimate the result; they do not change SQL semantics. Autovacuum normally performs analyzing automatically, but a bulk import or a skewed distribution can justify closer inspection. For correlated predicates, consider whether ordinary per-column statistics describe the combination accurately.
Do not make enable_seqscan = off your application's permanent fix. It is a diagnostic way to compare alternatives, and can encourage a worse plan. First establish which plan is actually better for representative data and parameters.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE state = 'closed';SELECT count(*) AS closed_events FROM events WHERE state = 'closed';Expected: A sequential scan over events; closed_events is 19800.
Match an expression to an expression index
Now ask for lower(state) = 'open'. The plain index stores state values, not the result of lower(state). A condition on a transformed value generally needs a suitable expression index, or a query rewrite that preserves the intended meaning.
The first plan shows the query before that index exists. Then create an index on lower(state), refresh statistics, and inspect the same query. The final plan should name events_lower_state_idx. The table values and matching row count did not change; the new access path matches the expression used by the predicate.
Real cases also involve casts, collations, partial-index predicates and operator classes. Compare the actual predicate and index definition instead of assuming that every appearance of a column can use every index on that column. Prepared statements add another wrinkle: a plan chosen without a specific parameter value may make a different tradeoff from your literal test query.
Reset the VM before repeating this before/after demonstration: after the first run, the expression index already exists. IF NOT EXISTS makes rerunning the creation harmless, but it cannot recreate the original missing-index situation.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE lower(state) = 'open';CREATE INDEX IF NOT EXISTS events_lower_state_idx ON events (lower(state));ANALYZE events;EXPLAIN (ANALYZE, BUFFERS)
SELECT id FROM events WHERE lower(state) = 'open';Expected: After creation, the final plan names events_lower_state_idx and returns 200 rows.
A useful diagnosis ends with evidence
Check the index definition, predicate, estimated and actual row counts, and buffer activity. Compare representative values. A sequential scan for a common value can be healthy; a selective query with a mismatched expression may need a different index.
Continue with EXPLAIN and EXPLAIN ANALYZE and planner statistics. PostgreSQL documents examining index usage and indexes on expressions. Keep the result of this experiment as a reasoning pattern, rather than a rule that one scan type is always faster.