4.4.15 · D4Databases

Exercises — EXPLAIN — reading query plans, cost estimation

3,917 words18 min readBack to topic

Figure — EXPLAIN — reading query plans, cost estimation
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.


Level 1 — Recognition

Recall Solution
  • 0.00 = startup cost — abstract work before the first row can be emitted. A Seq Scan emits row 1 almost instantly, so this is .
  • 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.


Level 2 — Application

Recall Solution

Plug in , : Why each term? You must touch all pages once (I/O term), emit each of the 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 (defined at the top of this page) = number of distinct values in the column, here . Why ? Equality selectivity assumes the values are uniformly distributed across the buckets, so a single value grabs one bucket's share, . See the Selectivity & Cardinality Estimation note for the non-uniform case.


Level 3 — Analysis

Recall Solution

Where does this formula come from? Build it term by term:

  1. Tree descent — why ? 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 prices. That's why we use (not ) here: we're doing key comparisons, not emitting rows.
    • Assumption flagged: writing the height as pretends each index page holds just 2 entries (fanout 2). Real B-trees pack hundreds of entries per page, so the true height is with fanout in the hundreds — i.e. only levels for millions of rows. We keep 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 .
  2. 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 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.)
  3. Random heap fetches — : for each of the matching keys we jump to wherever that row physically lives in the table (the heap), a random page hit priced at . This uses the page-4 simplifying assumption (one fetch per selected row); on a table clustered on user_id those rows would sit on consecutive pages and the fetches would be nearly sequential (cost each), making the index even cheaper — but with the number is already trivially small.
  4. Emit + filter each fetched row — : just like the Seq Scan formula, every fetched row must be emitted () and re-checked against the predicate (). Including the 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: , so .
  • Random heap fetches: .
  • Emit + filter: . Compare: , so the planner picks the Index Scan. Why? Reading pages randomly (at penalty each) is trivial next to reading all pages. The B-tree descent term is negligible here — indexes are shallow. This is the whole point of the figure below.

Figure — EXPLAIN — reading query plans, cost estimation
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 — the Seq Scan cost, constant no matter how many rows match. A red line rises from near zero with slope per selected row — the Index Scan cost. They cross at a yellow dashed vertical marker with a green dot at the break-even 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 descent term, and using the per-row cost that dominates — the extra per row shifts the answer by under ): As a selectivity: 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 ). This is why an index on a low-cardinality column (few distinct values → large ) is often useless on an unclustered table.


Level 4 — Synthesis

Recall Solution

(a) A massive estimation error. The planner thought only rows matched (so an index looked great), but 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 , using the same per-row formula as L3.1 (random fetch plus emit plus filter, i.e. per row — we keep the term this time so the model stays consistent with the formula we built): Assumption flagged: this again charges one random page fetch per row (the page-4 upper bound). On an unclustered events 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 . The chosen index plan is about 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 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, , which is spread evenly across all rows output rows. So the marginal cost of producing one row after startup is To emit only rows you pay the startup once (it cannot be split — the first row literally cannot appear until that work is done), then times the per-row marginal cost: 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 rows instead of all rows saves nothing on that fixed block. Only the per-row remainder shrinks with LIMIT.

Now evaluate both plans with and :

  • Plan A — its Sort has startup , which LIMIT cannot discount:
  • Plan B — the index emits pre-sorted rows immediately (startup ):

Comparison: . Plan B is roughly cheaper for this LIMIT 5, so the optimizer picks Plan B.

Why startup is the deciding number: Plan A's total () is only slightly above Plan B's () — if you asked for all rows they'd be near-tied. But Plan A's huge fixed startup () 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.


Level 5 — Mastery

Recall Solution

(a) Filtered rows:

(b) out of is a selectivity of — 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 pages () just to find 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 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: (Same page-4 assumption: one random fetch per probed customer, unclustered.) This beats a Hash Join that would have to hash all 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 . Index Scan cost (dominated by random fetches, one per selected row): Compare to : Verdict: the planner is correct. Fetching million rows one random page at a time (each 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 M 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.