Why indexes exist and when they win
When is a separate index worth building, and what exactly do we trade?
Giving up on the data
Before indexes, everything that makes a query fast works by arranging the data itself: layout, clustering, partitioning, sort order, column storage. All of these make the bytes you want land near each other. And all of them hit one hard limit:
Indexing is different because it gives up on the data. An index is not an arrangement of the data. It is a separate, derived, redundant structure whose only job is to answer "where is it?" Once you accept redundancy, the single-order limit disappears. You can have as many orderings as you are willing to pay for.
That is the trade. Three constraints stop mattering and three new ones appear.
| Gone | Arrived |
|---|---|
| Only one physical order | Write amplification: every index multiplies the cost of a write |
| The query must match the layout | Consistency: derived state can disagree with base state |
| Access cost scales with table size | The optimizer problem: an index that exists but is not chosen costs everything and buys nothing |
The governing question: when does the reader save more than the writer pays?
An index is worth building only when the reader saves more than the writer pays. The read side has a clean derivation, and it is worth doing, because the folklore version is everywhere and slightly wrong.
- A scan reads all N rows in order. Let s be the per-row cost of sequential access. Cost: N × s.
- An index lookup finds k matching rows by random access. Let r be the per-row cost of random access. The tree descent adds only a few reads, so we ignore it. Cost: about k × r.
The index wins when
- pages the index must fetch (one per matching row when unclustered)
- pages in the table
- cost of a sequential page read (seq_page_cost)
- cost of a random page read (random_page_cost)
Data table
| Random ÷ sequential cost r/s | series | Crossover (share of rows) |
|---|---|---|
| 1 | row for row (f = 1) | 100% |
| 1.06 | row for row (f = 1) | 94.4% |
| 1.12 | row for row (f = 1) | 89.1% |
| 1.19 | row for row (f = 1) | 84.1% |
| 1.26 | row for row (f = 1) | 79.4% |
| 1.33 | row for row (f = 1) | 75% |
| 1.41 | row for row (f = 1) | 70.8% |
| 1.5 | row for row (f = 1) | 66.8% |
| 1.58 | row for row (f = 1) | 63.1% |
| 1.68 | row for row (f = 1) | 59.6% |
| 1.78 | row for row (f = 1) | 56.2% |
| 1.88 | row for row (f = 1) | 53.1% |
| 2 | row for row (f = 1) | 50.1% |
| 2.11 | row for row (f = 1) | 47.3% |
| 2.24 | row for row (f = 1) | 44.7% |
| 2.37 | row for row (f = 1) | 42.2% |
| 2.51 | row for row (f = 1) | 39.8% |
| 2.66 | row for row (f = 1) | 37.6% |
| 2.82 | row for row (f = 1) | 35.5% |
| 2.99 | row for row (f = 1) | 33.5% |
| 3.16 | row for row (f = 1) | 31.6% |
| 3.35 | row for row (f = 1) | 29.9% |
| 3.55 | row for row (f = 1) | 28.2% |
| 3.76 | row for row (f = 1) | 26.6% |
| 3.98 | row for row (f = 1) | 25.1% |
| 4.22 | row for row (f = 1) | 23.7% |
| 4.47 | row for row (f = 1) | 22.4% |
| 4.73 | row for row (f = 1) | 21.1% |
| 5.01 | row for row (f = 1) | 20% |
| 5.31 | row for row (f = 1) | 18.8% |
| 5.62 | row for row (f = 1) | 17.8% |
| 5.96 | row for row (f = 1) | 16.8% |
| 6.31 | row for row (f = 1) | 15.8% |
| 6.68 | row for row (f = 1) | 15% |
| 7.08 | row for row (f = 1) | 14.1% |
| 7.5 | row for row (f = 1) | 13.3% |
| 7.94 | row for row (f = 1) | 12.6% |
| 8.41 | row for row (f = 1) | 11.9% |
| 8.91 | row for row (f = 1) | 11.2% |
| 9.44 | row for row (f = 1) | 10.6% |
| 10 | row for row (f = 1) | 10% |
| 10.6 | row for row (f = 1) | 9.44% |
| 11.2 | row for row (f = 1) | 8.91% |
| 11.9 | row for row (f = 1) | 8.41% |
| 12.6 | row for row (f = 1) | 7.94% |
| 13.3 | row for row (f = 1) | 7.5% |
| 14.1 | row for row (f = 1) | 7.08% |
| 15 | row for row (f = 1) | 6.68% |
| 15.8 | row for row (f = 1) | 6.31% |
| 16.8 | row for row (f = 1) | 5.96% |
| 17.8 | row for row (f = 1) | 5.62% |
| 18.8 | row for row (f = 1) | 5.31% |
| 20 | row for row (f = 1) | 5.01% |
| 21.1 | row for row (f = 1) | 4.73% |
| 22.4 | row for row (f = 1) | 4.47% |
| 23.7 | row for row (f = 1) | 4.22% |
| 25.1 | row for row (f = 1) | 3.98% |
| 26.6 | row for row (f = 1) | 3.76% |
| 28.2 | row for row (f = 1) | 3.55% |
| 29.9 | row for row (f = 1) | 3.35% |
| 31.6 | row for row (f = 1) | 3.16% |
| 33.5 | row for row (f = 1) | 2.99% |
| 35.5 | row for row (f = 1) | 2.82% |
| 37.6 | row for row (f = 1) | 2.66% |
| 39.8 | row for row (f = 1) | 2.51% |
| 42.2 | row for row (f = 1) | 2.37% |
| 44.7 | row for row (f = 1) | 2.24% |
| 47.3 | row for row (f = 1) | 2.11% |
| 50.1 | row for row (f = 1) | 2% |
| 53.1 | row for row (f = 1) | 1.88% |
| 56.2 | row for row (f = 1) | 1.78% |
| 59.6 | row for row (f = 1) | 1.68% |
| 63.1 | row for row (f = 1) | 1.58% |
| 66.8 | row for row (f = 1) | 1.5% |
| 70.8 | row for row (f = 1) | 1.41% |
| 75 | row for row (f = 1) | 1.33% |
| 79.4 | row for row (f = 1) | 1.26% |
| 84.1 | row for row (f = 1) | 1.19% |
| 89.1 | row for row (f = 1) | 1.12% |
| 94.4 | row for row (f = 1) | 1.06% |
| 100 | row for row (f = 1) | 1% |
| 1 | 100 rows per page | 1% |
| 1.06 | 100 rows per page | 0.944% |
| 1.12 | 100 rows per page | 0.891% |
| 1.19 | 100 rows per page | 0.841% |
| 1.26 | 100 rows per page | 0.794% |
| 1.33 | 100 rows per page | 0.75% |
| 1.41 | 100 rows per page | 0.708% |
| 1.5 | 100 rows per page | 0.668% |
| 1.58 | 100 rows per page | 0.631% |
| 1.68 | 100 rows per page | 0.596% |
| 1.78 | 100 rows per page | 0.562% |
| 1.88 | 100 rows per page | 0.531% |
| 2 | 100 rows per page | 0.501% |
| 2.11 | 100 rows per page | 0.473% |
| 2.24 | 100 rows per page | 0.447% |
| 2.37 | 100 rows per page | 0.422% |
| 2.51 | 100 rows per page | 0.398% |
| 2.66 | 100 rows per page | 0.376% |
| 2.82 | 100 rows per page | 0.355% |
| 2.99 | 100 rows per page | 0.335% |
| 3.16 | 100 rows per page | 0.316% |
| 3.35 | 100 rows per page | 0.299% |
| 3.55 | 100 rows per page | 0.282% |
| 3.76 | 100 rows per page | 0.266% |
| 3.98 | 100 rows per page | 0.251% |
| 4.22 | 100 rows per page | 0.237% |
| 4.47 | 100 rows per page | 0.224% |
| 4.73 | 100 rows per page | 0.211% |
| 5.01 | 100 rows per page | 0.2% |
| 5.31 | 100 rows per page | 0.188% |
| 5.62 | 100 rows per page | 0.178% |
| 5.96 | 100 rows per page | 0.168% |
| 6.31 | 100 rows per page | 0.158% |
| 6.68 | 100 rows per page | 0.15% |
| 7.08 | 100 rows per page | 0.141% |
| 7.5 | 100 rows per page | 0.133% |
| 7.94 | 100 rows per page | 0.126% |
| 8.41 | 100 rows per page | 0.119% |
| 8.91 | 100 rows per page | 0.112% |
| 9.44 | 100 rows per page | 0.106% |
| 10 | 100 rows per page | 0.1% |
| 10.6 | 100 rows per page | 0.094% |
| 11.2 | 100 rows per page | 0.089% |
| 11.9 | 100 rows per page | 0.084% |
| 12.6 | 100 rows per page | 0.079% |
| 13.3 | 100 rows per page | 0.075% |
| 14.1 | 100 rows per page | 0.071% |
| 15 | 100 rows per page | 0.067% |
| 15.8 | 100 rows per page | 0.063% |
| 16.8 | 100 rows per page | 0.06% |
| 17.8 | 100 rows per page | 0.056% |
| 18.8 | 100 rows per page | 0.053% |
| 20 | 100 rows per page | 0.05% |
| 21.1 | 100 rows per page | 0.047% |
| 22.4 | 100 rows per page | 0.045% |
| 23.7 | 100 rows per page | 0.042% |
| 25.1 | 100 rows per page | 0.04% |
| 26.6 | 100 rows per page | 0.038% |
| 28.2 | 100 rows per page | 0.036% |
| 29.9 | 100 rows per page | 0.034% |
| 31.6 | 100 rows per page | 0.032% |
| 33.5 | 100 rows per page | 0.03% |
| 35.5 | 100 rows per page | 0.028% |
| 37.6 | 100 rows per page | 0.027% |
| 39.8 | 100 rows per page | 0.025% |
| 42.2 | 100 rows per page | 0.024% |
| 44.7 | 100 rows per page | 0.022% |
| 47.3 | 100 rows per page | 0.021% |
| 50.1 | 100 rows per page | 0.02% |
| 53.1 | 100 rows per page | 0.019% |
| 56.2 | 100 rows per page | 0.018% |
| 59.6 | 100 rows per page | 0.017% |
| 63.1 | 100 rows per page | 0.016% |
| 66.8 | 100 rows per page | 0.015% |
| 70.8 | 100 rows per page | 0.014% |
| 75 | 100 rows per page | 0.013% |
| 79.4 | 100 rows per page | 0.013% |
| 84.1 | 100 rows per page | 0.012% |
| 89.1 | 100 rows per page | 0.011% |
| 94.4 | 100 rows per page | 0.011% |
| 100 | 100 rows per page | 0.01% |
The old rule of thumb "an index stops helping past about 5% selectivity" is not a law. It is s/r on 2005 hardware, rounded. PostgreSQL exposes the ratio directly as two settings, seq_page_cost and random_page_cost, with defaults 1.0 and 4.0. People are told to lower random_page_cost to about 1.1 on NVMe SSDs (very fast flash storage) because s/r moved a great deal when the mechanical seek went away. It is the same equation with different hardware and a different answer.
(With the defaults 1.0 and 4.0, a naive row-for-row reading gives a 25% crossover, not 5%; the page-level arithmetic below gives 0.25%, because many rows share a page, and caching moves it again. The crossover is a hardware ratio, not a constant.)
Worked example: the crossover with real numbers
parcels has N = 100,000,000 rows. With f = 100 rows per page it occupies 1,000,000 pages.
- Scan: read all pages sequentially. Cost = 1,000,000 pages × 1.0 = 1,000,000 units.
- Unclustered index lookup (matching rows scattered, one page each): cost = k × 4.0.
- The index wins when 4k < 1,000,000, so k < 250,000 rows, which is 0.25% of the table.
So for Priya's first index, if the status = 'delayed' filter matches 8% of the rows (8 million rows), the index is hopeless: 8,000,000 × 4 = 32,000,000 units against 1,000,000 for the scan. The planner is right to ignore it. On NVMe with random_page_cost = 1.1, the break-even rises to about 909,000 rows (0.9%). Still far below 8%.
Two corrections that matter more than the formula
1. The unit is pages, not rows. If the k matching rows lie on k different pages, you pay k random reads. If they are clustered together, you pay far fewer. This is why PostgreSQL keeps a per-column correlation statistic (how closely the column's order matches the physical row order). It is also why "clustered versus non-clustered" is the highest-leverage distinction in Index anatomy: clustering changes the number of pages k in the numerator, not the cost r.
2. r is not a constant. It is a step function. An index that fits in memory (the buffer pool) has one r. The same index, one byte too large for the buffer pool, has a different, much larger r. Indexes have cliffs, not curves. This is the general law "a fixed resource meeting exponential growth": index size grows with the data, memory does not, and the cost function jumps at the point where they cross.
What else moves the crossover
- With wide columnar rows, the rows-per-page count for the needed columns goes up enormously. That pushes the crossover down and favors scans. This is one mechanical reason analytic engines have few indexes.
- On object storage there is no random access at the page level at all. The question is no longer "index or scan" but "can I avoid fetching this object?", which per-file metadata answers with a skip instead of a lookup (sparse indexes and zone maps).
The planner has to make this choice before the query runs, from an estimate of k; how it gets that wrong near the crossover is access path selection.
The write side has no clean formula, and that is the point
Read benefit can be estimated. Write cost is certain. N indexes means roughly N times the write-path work, N times the space and N times the number of things that can break. That is why the honest default in most systems is "fewer indexes than you think", and why finding and dropping unused indexes is a standard production task.