4.4.15 · D2Databases

Visual walkthrough — EXPLAIN — reading query plans, cost estimation

2,931 words13 min readBack to topic

Step 1 — What a table actually looks like on disk

WHAT. Before any formula, picture the raw thing we are measuring. A table is not "rows floating in space." It is a stack of fixed-size pages (blocks of 8 kilobytes each). Every page holds a handful of rows.

WHY. The whole cost model counts page touches, so we must first agree on what a page is and how many there are. Three numbers will haunt every formula:

PICTURE. Below, one long table = a column of pages; each page is a small box holding a few row-slots. Count the boxes → that is . Count the slots → that is . Slots-per-box → that is .

Figure — EXPLAIN — reading query plans, cost estimation

Step 2 — Assigning a price to a single page touch

WHAT. We now put a price tag on the two ways to touch a page.

WHY. The database cannot predict wall-clock time (it depends on your disk, your cache, the weather). So it invents an abstract currency: reading one page in order costs exactly unit, by definition. Everything else is priced relative to that anchor.

Two ways to reach a page, and they cost differently — see Sequential vs Random I/O:

WHY is random costlier? A sequential read lets the disk head (or the OS read-ahead) march forward and grab the next physical block for almost free. A random read forces the head to seek to an arbitrary spot first. That seek is the expensive part.

PICTURE. Left: a green arrow gliding straight down neighbouring pages — each hop costs . Right: a coral arrow leaping to scattered pages — each hop costs .

Figure — EXPLAIN — reading query plans, cost estimation

Step 3 — Building the Seq Scan cost, term by term

WHAT. Assemble the cost of a sequential scan: read every page in order, then for every row, hand it to the machinery and run the filter once.

WHY. A sequential scan skips nothing. It cannot know which rows match amount > 500 until it looks, so it must:

  1. read all pages (in order → price each),
  2. process all rows (price each),
  3. test the filter on all rows (price each).

Three unavoidable jobs → three terms:

Because both and grow with table size, grows in a straight line — double the table, double the cost.

PICTURE. A horizontal bar split into three coloured segments sized like the real numbers from Example 1 (, , ). The I/O segment (pages) dominates.

Figure — EXPLAIN — reading query plans, cost estimation

Step 4 — The index route: a tree plus a scavenger hunt

WHAT. Now the rival plan. An index scan does not read the whole table. It walks a B-tree to find where the matching rows live, then jumps to fetch only those rows.

WHY use a tree at all? A B-tree is a sorted signpost. Instead of reading a million rows to find customer_id = 42, you descend a tree of height levels — a few dozen comparisons — and it points you straight to the matching rows. That descent answers the question "where are they?" cheaply.

WHY is the tree descent priced with and not a page cost? Two reasons. (1) At each of the levels the database does a comparison — "is my key left or right of this node?" — which is exactly what , the CPU per-operator cost, measures. That is why the descent term is : one comparison charge per level. (2) The index's own pages do have to be read from disk, but the upper levels of a B-tree are tiny and almost always sitting in memory (cache), so Postgres models their read cost as effectively free and charges only the CPU comparisons. We keep this term because it is honest, but note it is dust (, so ).

WHY does fetching rows cost random reads — and how many pages exactly? The tree tells you the matching rows live on pages scattered across the disk. Each fetch is a random jump (Step 2's coral arrow, priced ). But here is the subtlety: many rows share a page of them, from Step 1. So the true number of distinct page fetches is not but

If the matching rows are scattered one-per-page (the pessimistic default the planner often assumes for an unclustered index), then every fetched row lands on its own page and pages-fetched . That is the worst case, and it is what we use below to keep the algebra clean. When rows cluster (Step 7 case 3) far fewer pages are touched. So the term is the upper bound; reality can be cheaper.

PICTURE. A triangle (the B-tree) with a lavender path from root to a leaf, then coral arrows leaping out to scattered heap pages. Compare to Step 3's "read them all in order."

Figure — EXPLAIN — reading query plans, cost estimation

Step 5 — The showdown: which formula wins?

WHAT. Line the two costs up and cancel the tiny terms.

WHY. The tree-descent term () and CPU terms are dust compared to the page-reading terms. Strip them and the contest becomes brutally simple (using the unclustered worst case, pages-fetched ):

The index wins exactly when

In words: the index beats the scan only when it fetches fewer than a quarter of a table's-worth of pages. Fetch more than that and -costly random reads lose to cheap sequential reads.

PICTURE. Two straight lines on the same axes: (rows selected), cost. The flat lavender line is (constant — it always reads everything). The rising coral line is . They cross at the break-even point .

Figure — EXPLAIN — reading query plans, cost estimation

Step 6 — Turning "how many rows" into a fraction: selectivity

WHAT. The planner does not know in advance — it must estimate it before running anything.

WHY. To place your query on Step 5's x-axis, the planner needs . It gets there through selectivity , the estimated fraction of rows kept, then .

Where does the crossing land on the selectivity axis? Divide the break-even row count by to convert it from "rows" to "fraction":

PICTURE. A number line from to (selectivity). A tiny lavender tick near = "customer_id = 42, " → index territory. A fat coral tick near = "status = 'active'" → seq-scan territory. The dashed line at is the break-even.

Figure — EXPLAIN — reading query plans, cost estimation

Step 7 — Degenerate & edge cases (never let the reader fall off the map)

WHAT. Walk the boundaries where the tidy picture bends.

WHY. A formula you trust must survive its extremes. Five corners:

PICTURE. The Step 5 graph redrawn: (a) index line's endpoint at hugging the axis; (b) index line shooting far above the seq line at ; (c) a gentle clustered coral slope () whose crossing point slides far right; (d) SSD slope () also sliding right.

Figure — EXPLAIN — reading query plans, cost estimation

The one-picture summary

Everything above collapses into a single diagram: the two lines, their crossing at , the "index zone" (left, lavender) and "scan zone" (right, coral), with the two worked examples pinned where they fall, and the clustered line showing how the crossing can slide.

Recall Feynman retelling — say it like you'd explain to a friend

A table lives in blocks called pages, each holding rows. The only thing the database really pays for is touching a page. Touching the next page in line is cheap (call it ); jumping to a random page costs , because the disk has to seek.

A sequential scan just reads every page in order — its bill is basically "number of pages, times ." Flat. It doesn't care how many rows you want; it always reads the whole thing.

An index scan is smarter if you want few rows: it walks a little tree — paying one cheap CPU comparison per level, no disk cost because those tree pages live in memory — to find where the rows are, then jumps out to grab them. In the worst case each wanted row sits on its own page, so its bill is "rows wanted, times ." A rising line. But if the wanted rows cluster on the same pages, it pays for far fewer page jumps ( of them) and stays cheap for longer.

Draw both against "how many rows you're grabbing." The flat scan-line and the rising index-line cross at . Want fewer rows than that? Index wins. Want more? The scan wins, because a million cheap in-order reads beat a hundred-thousand expensive random jumps.

The planner just needs to know where you land on that axis — that's what selectivity estimates, and that's why stale ANALYZE stats (which move you to the wrong spot) are the number-one reason it picks the wrong line.

Recall Quick self-test

Break-even rows for an index vs seq scan (default constants)? ::: About — fetch fewer than a quarter of a table's-worth of pages. Why is a random page priced a sequential one? ::: Sequential reads let the disk march forward for free; random reads force a seek first. With LIMIT 1, which cost matters — startup or total? ::: Startup cost (defined just above) — because you stop after the first row (see LIMIT and Pagination). What does compute? ::: The estimated number of selected rows, from selectivity and total rows . Why is the tree-descent term charged with , not a page cost? ::: It counts one CPU comparison per tree level; the tiny upper index pages sit in memory, so their I/O is modelled as free. If the selected rows cluster on few pages, does the break-even move left or right? ::: Right — fewer page fetches () keep the index cheap for larger .