An Index Scan Is Not Automatically Faster
The planner chooses a sequential scan over an index for good reasons: selectivity, table size, cache residency and the cost of random page access. Forcing the index because "indexes are fast" is the most confidently made wrong optimization in database work.
Frame the diagnosis
Performance work starts from a symptom and a signal — never from a resource dashboard.
The planner is doing arithmetic you are not
An index scan is not a cheaper way to read rows. It is a *different* way, with a different cost curve. Reading through an index means descending the tree for each match, then usually jumping to the heap to fetch the row — random access, one page at a time, each potentially a separate storage read. A sequential scan reads pages in physical order, which storage and the operating system both prefetch well, and skips the tree entirely.
The crossover is selectivity. Below a few percent of the table, the index wins easily because it touches few pages. Above roughly a fifth of the table, the sequential scan usually wins, because the index path would touch nearly every heap page anyway plus the index pages, in random order, doing strictly more work. Between those, it depends on correlation between index order and physical row order, on whether the index covers the query, and on what is already in memory.
This is why the same query deserves different plans in different environments. On a laptop with a 500-row table, everything is in memory and a sequential scan is unbeatable. On production with 80 million rows and a cold cache, the index is decisive. A plan captured in the wrong environment is not evidence — it is a different question, answered correctly.
| Situation | Sequential scan | Index scan | Why |
|---|---|---|---|
| Predicate matches ~0.1% of rows | Reads every page | Wins clearly | Touches a handful of index and heap pages |
| Predicate matches ~30% of rows | Usually wins | Random-fetches most pages anyway | Index adds tree descents on top of nearly the same heap reads |
| Small table, fully cached | Wins | Overhead without benefit | No I/O to save; sequential access has near-zero cost |
| Index covers all selected columns | Reads the heap | Wins even at low selectivity | Index-only scan never touches the heap |
| Rows physically clustered by index order | Competitive | Wins | Index fetches become near-sequential heap reads |
| Rows randomly distributed, cold cache | Predictable | Can be far worse | Every match is a separate random page read |
| Query also sorts by the indexed column | Needs a sort step | Often wins | Index returns rows already ordered, no sort |
The misdiagnosis, and the measurement that prevents it
The failure mode is mechanical: see Seq Scan, add an index, ship it, and the query does not get faster — or gets faster in staging and not in production, which is worse because now there is a permanent write cost and a false belief. The planner had already considered that index and rejected it, and the rejection was probably correct.
The measurement that settles it takes one minute: count what the predicate actually matches, divide by the table size, and compare against what the planner estimated. If the planner's estimate is right and selectivity is high, the sequential scan is correct and the query needs a different shape — fewer rows requested, a narrower predicate, pagination, or precomputation. If the estimate is wrong, you are in The Slow Query Workflow's statistics case and the index is still not the fix.
There is a third case worth naming because it is common and quietly expensive: the predicate is selective, an index exists, and the planner still refuses it because the index cannot be used — a function applied to the column, a type mismatch forcing a cast, or a leading wildcard in a LIKE. The plan says sequential scan, the index exists, and everyone concludes the planner is broken. It is not; the predicate is simply not expressible through that index.
1-- Plan showed: Seq Scan on orders (actual rows=6100000)2CREATE INDEX orders_status_idx ON orders (status);3 4-- Re-run: planner still chooses Seq Scan. Why?5SELECT count(*) FILTER (WHERE status = 'active') AS matching,6 count(*) AS total7FROM orders;8 matching | total9----------+----------10 6100000 | 8600000 ← the predicate matches 71% of the table11 12-- The index was never going to help. It now costs a write13-- on every insert and status update, permanently.1-- Same question, asked before touching the schema:2-- 71% selectivity -> a sequential scan is the right access path.3-- The query is slow because it RETURNS six million rows.4 5-- The fix is at the query/API layer, not the index layer:6SELECT id, customer_id, total_cents, created_at7FROM orders8WHERE status = 'active'9 AND created_at > $1 -- keyset boundary, selective10ORDER BY created_at, id11LIMIT 100; -- bounded result12 13CREATE INDEX orders_active_created_idx14 ON orders (created_at, id)15 WHERE status = 'active'; -- partial: indexes only the hot subsetThe first version treats the access path as the problem. The second recognizes that returning 71% of a table is the problem, bounds the result, and only then adds an index that serves the bounded query — a partial index over the hot subset, which is smaller and cheaper to maintain than one over every row.
What a scan actually costs, and when that changes
A sequential scan's cost is proportional to table size in pages, and it degrades gracefully: twice the data, roughly twice the time, with excellent prefetching. An index scan's cost is proportional to matching rows, plus a tree descent each, plus a heap fetch each unless the index covers the query. That second curve is far better at low selectivity and far worse at high selectivity, and it is much more sensitive to whether pages are in memory.
Cache residency is the variable that makes production disagree with staging. If the table fits in the buffer pool, "random access" costs a memory lookup and the index wins over a wider range. If it does not, every heap fetch may be a storage read, and the index's advantage collapses exactly when the table is large enough to matter. This is the same working-set argument as Disk and Storage: Latency, Throughput, IOPS and the fsync Tax, seen from the database side, and the depth is in The Buffer Pool.
The practical consequence for diagnosis: when a query flips from fast to slow with no code change and no data-shape change, suspect the working set outgrowing memory before suspecting the planner. The buffer hit ratio and shared read= counts in the plan tell you directly, and the fix is capacity or partitioning rather than anything in the query.
| Signal | Value | What it tells you | Verdict |
|---|---|---|---|
| Plan shape | Index Scan (unchanged) | The planner made the same decision both times. Not a plan flip. | normal |
| Rows returned | ~2,400 (unchanged) | Selectivity is stable. Not a data-distribution change. | normal |
| Buffers: shared hit | 2,390 → 210 | Pages found in memory collapsed. | suspect |
| Buffers: shared read | 10 → 2,190 | Nearly every heap fetch is now a storage read. The random-access cost became real. | smoking gun |
| Table size | 48 GB → 71 GB | The working set outgrew the buffer pool. This is a capacity change, not a query change. | suspect |
| Query duration p99 | 11 ms → 780 ms | The symptom. Identical plan, identical rows, 70× slower. | normal |
Key points
- An index scan trades sequential page reads for random ones; it wins at low selectivity and loses at high selectivity.
- Selectivity is the fraction of the table matched, not the number of rows returned — 200 rows is selective on a billion-row table and not on a small one.
- A
Seq Scanin the plan is frequently the correct choice; the planner already considered your index and rejected it. - Covering (index-only) scans and index-ordered clustering both widen the range where the index wins.
- A query can slow 70× with no plan change when the working set outgrows memory — that is a capacity signal, not a query signal.
Follow the diagnosis
The causal chain, hop by hop — and the readings that invite the wrong conclusion.
- 1Plan → suspicion:
Seq Scan on orders, 6.1 M rows, 2.3 s — reads as an obvious missing index. - 2Selectivity → reality: the predicate matches 71% of the table, so an index scan would random-fetch nearly every heap page plus index pages.
- 3Planner → verdict: the sequential scan is the cheaper path and the planner chose correctly; the index would be rejected even if it existed.
- 4Query → root cause: the query returns six million rows to the application, so the cost is the result size, not the access path.
- 5Root cause → layer: the fix is bounding the result (keyset pagination, a narrower predicate), then a partial index sized to the bounded query.
- • "Seq Scan means a missing index." It usually means the predicate is not selective enough for an index to pay off.
- • "The index exists, so the planner should use it." A cast, a function on the column, or a leading wildcard can make the predicate unusable through that index.
- • "It was fast in staging with the same plan." Staging fits in memory. Random access is cheap until it is not.
- • "Adding the index is harmless if the planner ignores it." It is not free: every insert and update maintains it forever.
- • "More rows returned means we need a better index." Returning six million rows is the problem regardless of how they are found.
Measure, fix, validate
An optimization is not finished until the metric that motivated it has moved.
- • Selectivity directly: `count(*) FILTER (WHERE <predicate>)` over `count(*)`, on production-shaped data.
- • Estimated versus actual rows at the scan node, to separate "the scan is right" from "the estimate is wrong".
- • `Buffers: shared hit` versus `shared read` in the plan — the ratio that decides how expensive random access actually is.
- • Table and index size against available buffer pool memory, tracked over time so the crossover is predicted rather than discovered.
- • Write latency on the table before and after any index addition, since that cost is permanent.
- • Bound the result first: keyset pagination, narrower predicates, aggregate in the database instead of shipping rows ([[pagination]] covers the contract side).
- • Where a selective predicate genuinely lacks support, add a composite index ordered to serve filter and sort together — or a partial index over the hot subset, which is smaller and cheaper to maintain.
- • Make the predicate index-usable: match types to avoid casts, index the expression if a function is unavoidable, avoid leading wildcards.
- • Consider a covering index when the query selects few columns, converting heap fetches into an index-only scan.
- • When the working set has outgrown memory, treat it as capacity: more RAM, or partition the hot data ([[partitioning-and-sharding]]).
- • Confirm the plan node actually changed — not merely that the timing improved on a warm cache during the test.
- • Re-run cold: a second execution reading from memory proves nothing about the first execution of the day.
- • Measure insert and update latency on the table after adding any index, and keep the number next to the read improvement.
- • Compare endpoint p99 in production over a window matching the baseline, since a faster access path on a non-critical query moves nothing.
- • Every index slows writes and consumes storage and memory that the buffer pool would otherwise use for data.
- • Partial indexes are cheaper but only serve queries whose predicate matches the index condition — a slightly different query silently falls back to a scan.
- • Covering indexes duplicate column data, which can make the index large enough to lose the memory it was trying to save.
- • Rewriting to keyset pagination removes random page access and also removes the ability to jump to page 400 ([[pagination]] states that trade-off in contract terms).
- • Track index size and table size against buffer pool capacity, with an alert before the working set crosses it.
- • Monitor unused indexes and remove them; each one is a permanent write tax paid for a decision nobody remembers.
- • Assert plan shape in CI for the handful of statements on the critical path, against a production-shaped dataset.
Accuracy
Performance numbers are conditional. These are the conditions.
- DATABASE-SPECIFICSelectivity crossover points, clustering behavior and index-only-scan rules differ by engine. PostgreSQL needs a visibility-map check for index-only scans; InnoDB clusters the table by primary key, which changes the arithmetic entirely.
- ILLUSTRATIVEThe percentages and the two-week comparison are teaching shapes. Real crossover depends on row width, page size, storage characteristics and cache state.
- ENVIRONMENT-SPECIFICThe same query on the same schema deserves different plans on a laptop and in production, because cache residency and table size differ.
Misconceptions
Where the depth lives
The reason a scan degrades gracefully and an index scan does not is prefetching: the kernel and the storage device both predict sequential access and cannot predict random access.