Skip to content
About
Main content

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.

GoneArrived
Only one physical order: every index multiplies the cost of a write
The query must match the layoutConsistency: can disagree with base state
Access cost scales with table sizeThe 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 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)
row for row (f = 1)100 rows per page
Data table
Largest share of rows an unclustered index can return and still win
Random ÷ sequential cost r/sseriesCrossover (share of rows)
1row for row (f = 1)100%
1.06row for row (f = 1)94.4%
1.12row for row (f = 1)89.1%
1.19row for row (f = 1)84.1%
1.26row for row (f = 1)79.4%
1.33row for row (f = 1)75%
1.41row for row (f = 1)70.8%
1.5row for row (f = 1)66.8%
1.58row for row (f = 1)63.1%
1.68row for row (f = 1)59.6%
1.78row for row (f = 1)56.2%
1.88row for row (f = 1)53.1%
2row for row (f = 1)50.1%
2.11row for row (f = 1)47.3%
2.24row for row (f = 1)44.7%
2.37row for row (f = 1)42.2%
2.51row for row (f = 1)39.8%
2.66row for row (f = 1)37.6%
2.82row for row (f = 1)35.5%
2.99row for row (f = 1)33.5%
3.16row for row (f = 1)31.6%
3.35row for row (f = 1)29.9%
3.55row for row (f = 1)28.2%
3.76row for row (f = 1)26.6%
3.98row for row (f = 1)25.1%
4.22row for row (f = 1)23.7%
4.47row for row (f = 1)22.4%
4.73row for row (f = 1)21.1%
5.01row for row (f = 1)20%
5.31row for row (f = 1)18.8%
5.62row for row (f = 1)17.8%
5.96row for row (f = 1)16.8%
6.31row for row (f = 1)15.8%
6.68row for row (f = 1)15%
7.08row for row (f = 1)14.1%
7.5row for row (f = 1)13.3%
7.94row for row (f = 1)12.6%
8.41row for row (f = 1)11.9%
8.91row for row (f = 1)11.2%
9.44row for row (f = 1)10.6%
10row for row (f = 1)10%
10.6row for row (f = 1)9.44%
11.2row for row (f = 1)8.91%
11.9row for row (f = 1)8.41%
12.6row for row (f = 1)7.94%
13.3row for row (f = 1)7.5%
14.1row for row (f = 1)7.08%
15row for row (f = 1)6.68%
15.8row for row (f = 1)6.31%
16.8row for row (f = 1)5.96%
17.8row for row (f = 1)5.62%
18.8row for row (f = 1)5.31%
20row for row (f = 1)5.01%
21.1row for row (f = 1)4.73%
22.4row for row (f = 1)4.47%
23.7row for row (f = 1)4.22%
25.1row for row (f = 1)3.98%
26.6row for row (f = 1)3.76%
28.2row for row (f = 1)3.55%
29.9row for row (f = 1)3.35%
31.6row for row (f = 1)3.16%
33.5row for row (f = 1)2.99%
35.5row for row (f = 1)2.82%
37.6row for row (f = 1)2.66%
39.8row for row (f = 1)2.51%
42.2row for row (f = 1)2.37%
44.7row for row (f = 1)2.24%
47.3row for row (f = 1)2.11%
50.1row for row (f = 1)2%
53.1row for row (f = 1)1.88%
56.2row for row (f = 1)1.78%
59.6row for row (f = 1)1.68%
63.1row for row (f = 1)1.58%
66.8row for row (f = 1)1.5%
70.8row for row (f = 1)1.41%
75row for row (f = 1)1.33%
79.4row for row (f = 1)1.26%
84.1row for row (f = 1)1.19%
89.1row for row (f = 1)1.12%
94.4row for row (f = 1)1.06%
100row for row (f = 1)1%
1100 rows per page1%
1.06100 rows per page0.944%
1.12100 rows per page0.891%
1.19100 rows per page0.841%
1.26100 rows per page0.794%
1.33100 rows per page0.75%
1.41100 rows per page0.708%
1.5100 rows per page0.668%
1.58100 rows per page0.631%
1.68100 rows per page0.596%
1.78100 rows per page0.562%
1.88100 rows per page0.531%
2100 rows per page0.501%
2.11100 rows per page0.473%
2.24100 rows per page0.447%
2.37100 rows per page0.422%
2.51100 rows per page0.398%
2.66100 rows per page0.376%
2.82100 rows per page0.355%
2.99100 rows per page0.335%
3.16100 rows per page0.316%
3.35100 rows per page0.299%
3.55100 rows per page0.282%
3.76100 rows per page0.266%
3.98100 rows per page0.251%
4.22100 rows per page0.237%
4.47100 rows per page0.224%
4.73100 rows per page0.211%
5.01100 rows per page0.2%
5.31100 rows per page0.188%
5.62100 rows per page0.178%
5.96100 rows per page0.168%
6.31100 rows per page0.158%
6.68100 rows per page0.15%
7.08100 rows per page0.141%
7.5100 rows per page0.133%
7.94100 rows per page0.126%
8.41100 rows per page0.119%
8.91100 rows per page0.112%
9.44100 rows per page0.106%
10100 rows per page0.1%
10.6100 rows per page0.094%
11.2100 rows per page0.089%
11.9100 rows per page0.084%
12.6100 rows per page0.079%
13.3100 rows per page0.075%
14.1100 rows per page0.071%
15100 rows per page0.067%
15.8100 rows per page0.063%
16.8100 rows per page0.06%
17.8100 rows per page0.056%
18.8100 rows per page0.053%
20100 rows per page0.05%
21.1100 rows per page0.047%
22.4100 rows per page0.045%
23.7100 rows per page0.042%
25.1100 rows per page0.04%
26.6100 rows per page0.038%
28.2100 rows per page0.036%
29.9100 rows per page0.034%
31.6100 rows per page0.032%
33.5100 rows per page0.03%
35.5100 rows per page0.028%
37.6100 rows per page0.027%
39.8100 rows per page0.025%
42.2100 rows per page0.024%
44.7100 rows per page0.022%
47.3100 rows per page0.021%
50.1100 rows per page0.02%
53.1100 rows per page0.019%
56.2100 rows per page0.018%
59.6100 rows per page0.017%
63.1100 rows per page0.016%
66.8100 rows per page0.015%
70.8100 rows per page0.014%
75100 rows per page0.013%
79.4100 rows per page0.013%
84.1100 rows per page0.012%
89.1100 rows per page0.011%
94.4100 rows per page0.011%
100100 rows per page0.01%

The old rule of thumb "an index stops helping past about 5% " 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 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 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 , has a different, much larger r. 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. 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.