Figure 1 — alt-text / walkthrough: a dark-navy diagram in two horizontal rows of twelve blue-outlined page boxes. Top row = a Seq Scan: eleven blue left-to-right arrows chain across every page, one after another, labelled "walk ALL pages in order (cheap each, cost 1.0)". Bottom row = an Index Scan: a yellow "index" node in the centre sends three red arrows down to only three red-outlined page boxes at scattered positions (page 2, 6, 10), labelled "jump to a FEW pages randomly (4x costlier each)". The visual contrast — a continuous blue chain versus three isolated red jumps — is the entire trade-off: many cheap sequential reads against few costly random reads.
The figure above is the mental model for everything: a Seq Scan walks every page once, cheaply; an Index Scan jumps to a few pages, expensively each. Whichever total is smaller wins.
0.00 = startup cost — abstract work before the first row can be emitted. A Seq Scan emits row 1 almost instantly, so this is ≈0.
2550.00 = total cost — abstract work to emit the last row.
rows=40000 = the planner's estimate of how many rows this node emits (not measured, predicted from statistics).
width=52 = estimated average bytes per row.
Nothing here is measured — EXPLAIN without ANALYZE never runs the query.
Recall Solution
The Seq Scan runs first. Execution flows bottom-up / inside-out: the more-indented node is a child that feeds its parent. The Seq Scan reads the 40 000 rows and hands them up to the Sort, which then orders them. The top-most node (Sort) is the last step — it produces the final output.
Plug in P=5000, T=300000:
Cseq=5000(1.0)+300000(0.01+0.0025)=5000+300000(0.0125)=5000+3750=8750.Why each term? You must touch all 5000 pages once (I/O term), emit each of the 300000 rows (cpu_tuple_cost), and test the filter on each row (cpu_operator_cost). Postgres would print cost=0.00..8750.00.
Recall Solution
Recall ndistinct (defined at the top of this page) = number of distinct values in the column, here 15,000.
s=ndistinct1=150001≈6.667×10−5.S=s⋅T=15000300000=20 rows.Why 1/ndistinct? Equality selectivity assumes the values are uniformly distributed across the ndistinct buckets, so a single value grabs one bucket's share, 1/ndistinct. See the Selectivity & Cardinality Estimation note for the non-uniform case.
Where does this formula come from? Build it term by term:
Tree descent — why log2(T)⋅cop? A B-tree finds a key by comparing at each level and stepping down; the number of levels grows like a logarithm of the number of stored entries, so we make a logarithmic number of comparisons. Each comparison is one operator evaluation, which is exactly what cop prices. That's why we use cop (not ctuple) here: we're doing key comparisons, not emitting rows.
Assumption flagged: writing the height as log2(T) pretends each index page holds just 2 entries (fanout 2). Real B-trees pack hundreds of entries per page, so the true height is logf(T) with fanoutf in the hundreds — i.e. only 3–4 levels for millions of rows. We keep log2(T) because it over-counts the descent, and the descent term is tiny either way, so the winner never changes. Just know the real height depends on fanout and entries-per-page, not on log2.
Why neglect the index-page I/O? Descending a B-tree touches only a few internal index pages, and the upper levels are tiny and almost always already cached in memory. A handful of cached page reads is negligible next to the S random heap fetches below, so the simplified model drops it. (Postgres's real model adds a small index-I/O term; it never changes the winner in these examples.)
Random heap fetches — S⋅crand: for each of the S matching keys we jump to wherever that row physically lives in the table (the heap), a random page hit priced at crand=4.0. This uses the page-4 simplifying assumption (one fetch per selected row); on a table clustered on user_id those S rows would sit on consecutive pages and the fetches would be nearly sequential (cost ≈cseq each), making the index even cheaper — but with S=20 the number is already trivially small.
Emit + filter each fetched row — S(ctuple+cop): just like the Seq Scan formula, every fetched row must be emitted (ctuple) and re-checked against the predicate (cop). Including the cop term here keeps the model consistent with the Seq Scan cost in L2.1 — both charge one operator evaluation per row they process.
Now the numbers:
Tree descent: log2(300000)≈18.19, so 18.19×0.0025≈0.0455.
Random heap fetches: S⋅crand=20×4.0=80.
Emit + filter: S(ctuple+cop)=20×(0.01+0.0025)=20×0.0125=0.25.
Cidx≈0.0455+80+0.25≈80.3.
Compare: 80.3≪8750, so the planner picks the Index Scan.
Why? Reading 20 pages randomly (at 4× penalty each) is trivial next to reading all 5000 pages. The B-tree descent term is negligible here — indexes are shallow. This is the whole point of the figure below.
Figure 2 — alt-text / walkthrough: a cost-vs-workload line chart on dark navy. The x-axis is labelled "S = selected rows" (0 to 4000 rows); the y-axis is labelled "estimated cost (abstract units)" (0 to 18000). A horizontal blue line sits flat at 8750 — the Seq Scan cost, constant no matter how many rows match. A red line rises from near zero with slope ≈crand+ctuple=4.01 per selected row — the Index Scan cost. They cross at a yellow dashed vertical marker with a green dot at the break-even S≈2182 rows (≈0.7% of the table). Left of the marker (green "index WINS" region) the red line is below the blue; right of it (blue "Seq Scan WINS" region) the red line is above. Note: because our model charges one random fetch per row, this red line is an upper bound — a clustered heap would tilt it flatter and push break-even rightward.
Recall Solution
Set the two costs equal (dropping the ≈0.05 descent term, and using the per-row cost crand+ctuple that dominates — the extra cop per row shifts the answer by under 0.1%):
S⋅crand+S⋅ctuple=8750⟹S(4.0+0.01)=8750.S=4.018750≈2182 rows.
As a selectivity:
s⋆=TS=3000002182≈0.00727≈0.73%.Interpretation: if the predicate keeps fewer than ~0.7% of rows, the index wins; more than that, Seq Scan wins. This threshold assumes one random fetch per row; on a table clustered on the indexed column the fetches turn sequential, so the break-even shifts much higher (an index can pay off even at large S). This is why an index on a low-cardinality column (few distinct values → large S) is often useless on an unclustered table.
(a) A massive estimation error. The planner thought only 20 rows matched (so an index looked great), but 240000 actually match. This means the statistics are stale or wrong — probably 'archived' became a very common value after the stats were last gathered. This is the #1 cause of bad plans.
(b) Actual index cost with S=240000, using the same per-row formula as L3.1 (random fetch plus emit plus filter, i.e. crand+ctuple+cop per row — we keep the cop term this time so the model stays consistent with the formula we built):
Cidx≈240000(crand+ctuple+cop)=240000(4.0+0.01+0.0025)=240000×4.0125=963000.Assumption flagged: this again charges one random page fetch per row (the page-4 upper bound). On an unclusteredevents table that is realistic; if the table happened to be clustered on status, the fetches would be sequential and the index would be far less disastrous. We assume unclustered (the common case).
Versus Cseq=8750. The chosen index plan is about
8750963000≈110× worse
than a plain Seq Scan would have been — a catastrophe caused purely by the wrong estimate.
(c)ANALYZE events; — refresh the statistics in pg_statistic so the planner sees the true frequency of 'archived' and switches to Seq Scan. See Database Statistics & ANALYZE.
Recall Solution
Plan B wins. With LIMIT 5 you stop after the first 5 rows, so the optimizer estimates cost proportional to how quickly rows start flowing, not the full total.
Where does the LIMIT heuristic come from? (deriving it) A node's total cost is split into two parts: the startup cost — work that must finish before row 1 appears — and the remaining work, (total−startup), which is spread evenly across all rows output rows. So the marginal cost of producing one row after startup is
rowstotal−startup.
To emit only k rows you pay the startup once (it cannot be split — the first row literally cannot appear until that work is done), then k times the per-row marginal cost:
Climit k≈startup+rowsk(total−startup).Why startup cannot be amortized: startup is setup that happens before any output exists (e.g. a Sort must read and order all input before it can hand over even the smallest row). There is no "partial startup" — asking for 5 rows instead of all rows saves nothing on that fixed block. Only the per-row remainder shrinks with LIMIT.
Now evaluate both plans with k=5 and rows=40000:
Plan A — its Sort has startup 9000, which LIMIT cannot discount:
Climit 5A≈9000+400005(9100−9000)=9000+400005(100)=9000+0.0125≈9000.01.
Plan B — the index emits pre-sorted rows immediately (startup 0.5):
Climit 5B≈0.5+400005(8800−0.5)=0.5+400005(8799.5)≈0.5+1.10≈1.60.
Comparison:Climit 5B≈1.60≪Climit 5A≈9000.01. Plan B is roughly 5600× cheaper for this LIMIT 5, so the optimizer picks Plan B.
Why startup is the deciding number: Plan A's total (9100) is only slightly above Plan B's (8800) — if you asked for all rows they'd be near-tied. But Plan A's huge fixed startup (9000) is charged in full no matter how few rows you want, while Plan B starts emitting almost instantly. LIMIT rewards low startup. See LIMIT and Pagination.
(b)S=10000 out of 2,000,000 is a selectivity of 0.5% — below typical break-even (~0.7% from L3.2), so an Index Scan on a status index (or a partial index on pending orders) beats the Seq Scan for the orders side. Without such an index, Postgres must Seq Scan all 40000 pages (Cseq=40000+2×106×0.0125=40000+25000=65000) just to find 10000 rows — wasteful. So yes, an index on status helps here.
(c) A Nested Loop join with an index lookup on the inner side. Outer input = the 10000 pending orders (tiny). For each one, probe the customers primary-key index on id — a logarithmically-cheap B-tree descent per row plus one random heap fetch:
≈10000×(log2(200000)×cop+crand)≈10000×(17.6×0.0025+4)≈10000×4.044≈40,440.
(Same page-4 assumption: one random fetch per probed customer, unclustered.) This beats a Hash Join that would have to hash all 200000 customers, because the outer side is small and the inner side is index-covered. If the outer side were large (say millions of pending orders), a Hash Join would win instead — build one hash table, no per-row random probes.
Rule of thumb learned: small filtered outer + indexed inner ⇒ Nested Loop; large unindexed inputs ⇒ Hash Join.
Recall Solution
Selected rows S=0.92×107=9,200,000.
Index Scan cost (dominated by random fetches, one per selected row):
Cidx≈S×crand+S×ctuple=9.2×106×4.0+9.2×106×0.01=36,800,000+92,000=36,892,000.
Compare to Cseq=325,000:
325,00036,892,000≈114× worse for the index.Verdict: the planner is correct. Fetching 9.2 million rows one random page at a time (each 4× costlier) is catastrophic versus streaming the whole table sequentially. The index is useless for this predicate because the predicate keeps almost everything — a low-selectivity filter. Caveat (clustering): if this table were physically clustered on is_deleted, the 9.2M fetches would be sequential rather than random, shrinking the index cost dramatically — but the default is unclustered, so the planner's Seq Scan choice stands. The Query Optimizer chose wisely.