Knowledge Guide
HomeDatabasesQuery Execution

Query Execution — Selectivity, the Cost Model, Join Algorithms, Spills & Sargability (Deep Dive)

The single most common query-execution interview question is “there’s an index on that column — why did the planner run a sequential scan anyway?” The one-line mechanism: an index match costs one random heap-page fetch, while a sequential scan pays for pages in the cheapest possible order — one linear pass, heavily prefetched. So once a predicate matches more than a small slice of the table, paying for many scattered single-row fetches costs more in total than just reading every page once. Above that crossover point, the seq scan is the objectively cheaper plan, not a planner mistake.

This page assumes you already know the iterator model (operators pull rows from their children one at a time) and that EXPLAIN prints a cost. Here we go one level deeper into the arithmetic and mechanisms an interviewer actually probes: selectivity, the cost model’s startup/total split, why join algorithms are constrained by predicate shape, what a hash join does when it can’t fit in memory, how to write predicates an index can actually use, and three failure modes that hide inside a single EXPLAIN line.

Selectivity: why the planner sometimes ignores your index

Selectivity is the fraction of a table’s rows a predicate matches. It is the single number that decides between an index scan and a sequential scan, because the two access paths have completely different cost shapes:

A flat line and a rising line cross exactly once. Below the crossover the index wins; above it the seq scan wins — and a plan that “ignores your index” is usually doing the arithmetic correctly.

Traced example

Table orders: 10,000,000 rows, ~100 rows per 8 KB page ⇒ 100,000 pages. Using PostgreSQL’s default cost constants (seq_page_cost=1, random_page_cost=4, cpu_tuple_cost=0.01, cpu_index_tuple_cost=0.005):

Seq Scan cost  = seq_page_cost × pages + cpu_tuple_cost × rows
               = 1 × 100,000 + 0.01 × 10,000,000
               = 100,000 + 100,000 = 200,000        (flat — independent of the predicate)

Index Scan cost (per matching row, worst case — no heap/index correlation)
               = random_page_cost + cpu_index_tuple_cost + cpu_tuple_cost
               = 4 + 0.005 + 0.01 = 4.015 per row

Crossover: 4.015 × m = 200,000  ⇒  m ≈ 49,800 rows  ⇒  selectivity ≈ 0.5%

So for this table, once a predicate matches more than roughly 0.5% of rows, the planner should — correctly — switch to a sequential scan, even with a perfectly good index sitting there.

Three things move that 0.5% number, all real and all interview-relevant:

LeverEffect on the crossover
Row width (rows per page)Fewer, wider rows per page means fewer total pages to seq-scan but the same linear cost per index match — pushes the crossover up. A table with only ~8 rows/page (wide rows) crosses over closer to ~3–4% by the same arithmetic.
Buffer cache warmthThe 4× random_page_cost models a cold, disk-bound random read. If the working set is mostly resident in RAM (or on SSD), a “random” fetch is nearly as cheap as sequential — shops often tune random_page_cost down to ~1.1, which pushes the crossover up sharply.
Physical correlationIf the index order roughly matches the heap’s physical order (PostgreSQL’s correlation statistic near ±1 — typical for a monotonically increasing id or timestamp), consecutive index matches land on the same or adjacent heap pages instead of scattering — pushing the crossover up, sometimes past 20%.

This is why the commonly-quoted rule of thumb is a range (“roughly 5–20%”) rather than one fixed number: the naive per-row model above gives the pessimistic floor, and every one of these three factors only ever pushes the real crossover higher.

Selectivity crossover chart: seq scan cost flat at 200,000, index scan cost rising linearly, crossing at about 0.5 percent selectivity
Selectivity crossover chart: seq scan cost flat at 200,000, index scan cost rising linearly, crossing at about 0.5 percent selectivity

The cost model: startup vs. total, and why LIMIT changes the winner

EXPLAIN prints every node as cost=startup..total. Startup cost is the work that must happen before the operator can hand back its first row; total cost is the cost to hand back every row. Most operators (a scan, a nested loop) can start streaming almost immediately, so their startup cost is near zero. A few operators are blocking: a Sort cannot emit row 1 until every input row has been read and the whole sort is complete, so its startup cost is (almost) equal to its total cost. The build side of a hash join is blocking in the same way.

This matters enormously whenever a query has a LIMIT. The planner estimates the cost of producing just the first k rows as startup + (total − startup) × (k / estimated_rows) — and a plan with a lower full-run total cost can still lose to a plan with a higher full-run total cost, if the winning plan can start streaming almost immediately while the “cheaper” plan must pay its entire cost up front.

Traced example

SELECT * FROM orders ORDER BY created_at DESC LIMIT 10; against the same 10,000,000-row table, with an index on created_at:

Plan A: Sort(all 10M rows) -> Limit 10
  Sort must consume + sort all 10M rows before it can emit row 1.
  cost of sorting ≈ 2·N·log2(N)·cpu_operator_cost = 2×10,000,000×23.25×0.0025 ≈ 1,162,650
  plus reading the source rows (same as the Seq Scan above)          ≈   200,000
  startup ≈ total ≈ 1,362,650   (blocking — startup ≈ total)
  Limit(10) cost ≈ 1,362,650          (pays the whole sort regardless of how few rows you keep)

Plan B: Index Scan Backward (created_at) -> Limit 10
  Descending the index to the newest row costs a handful of page reads.
  startup ≈ 4.5
  total (if run to completion, worst case — no index/heap correlation) ≈ N × 4.015 ≈ 40,150,000
  Limit(10) cost = startup + (total − startup) × (10 / 10,000,000)
                 ≈ 4.5 + 40.15 ≈ 44.65

Plan B’s full-run total cost (40,150,000) is over 25× worse than Plan A’s (1,362,650) — if the query had no LIMIT, Plan A would win easily. But with LIMIT 10, Plan B needs only about 45 cost units against Plan A’s 1,362,650, a roughly 30,000× gap in the other direction — because Plan B can start streaming the newest row after a handful of page reads, while Plan A cannot emit anything until the entire sort finishes. (In practice a monotonically increasing created_at is usually well-correlated with physical insertion order, which would make Plan B’s full-run cost far cheaper than this pessimistic bound — but it doesn’t even matter here, since LIMIT means neither plan ever pays the full-run cost.) This is exactly why an index that matches your ORDER BY is worth more than its raw selectivity would suggest: it turns a blocking operator into a streaming one.

Join algorithms: nested loop, hash, sort-merge — and why some predicates force one of them

AlgorithmMechanismPredicate supportWins when
Nested loopFor every outer row, probe the inner side (ideally via an index). Cost ≈ |outer| × cost_per_inner_probeper outer row, so it lives or dies on the outer cardinality.Any predicate — equality, range, <, >, even a function call.Outer side is small, or the inner side has a highly selective index. The only algorithm that survives a non-equality (theta) predicate.
Hash joinBuild an in-memory hash table on the smaller (“build”) side keyed on the join column, then stream the larger (“probe”) side past it doing O(1) lookups. Probe cost is per outer row regardless of order — no sorting needed.Equality only (=). A hash table can’t answer “is there a key less than X” — there is no useful bucket for a range.Large, unsorted equi-join inputs where the build side fits in work_mem.
Sort-mergeSort both inputs on the join key (or reuse existing order, e.g. from an index), then walk both sorted streams with two pointers, advancing whichever side is behind.Equality, and in principle range/inequality too if both sides are already ordered — but engines only generate it for equijoins in practice, since sorting from scratch just to support a range join rarely beats nested loop.Both inputs are already sorted on the join key (e.g. delivered by an index scan), or a sort is needed downstream anyway (ORDER BY/GROUP BY on the same key).

The interview-decisive fact: a theta join — any join condition that isn’t a plain equality, e.g. ON a.start_ts <= b.end_ts AND a.end_ts >= b.start_ts (interval overlap) or ON a.price > b.min_pricecannot use a hash join (no single hash bucket represents “all keys less than mine”), and in practice can’t use sort-merge either without an expensive from-scratch sort whose benefit rarely pays for itself. The planner is left with nested loop as close to the only option. This is why interval-overlap and range-comparison joins are notoriously slow at scale, and why the fix is almost always structural — add a predicate that turns part of the condition into an equality (e.g. bucket timestamps into equality-joinable time buckets) — not “pick a different join algorithm.”

Grace / hybrid hash spill: what happens when the build side doesn’t fit

A plain hash join assumes the build side’s hash table fits in work_mem. When it doesn’t, the engine does not fall back to nested loop — it partitions. The mechanism, not just “spills to disk”:

  1. Phase 1 — partition both sides. Using the same hash function h(key), split both the build relation R and the probe relation S into k partitions (say 4) by h(key) mod k, writing each partition to its own disk file. Because the same hash function and modulus are used on both sides, every row pair that could ever match lands in the same numbered partition on both sides — R0 can only match S0, R1 only S1, and so on. No pair that matters is ever split across partitions.
  2. Phase 2 — process pair-by-pair. For each partition i, build an in-memory hash table on Ri (now roughly 1/k the size of the original — small enough to fit) and stream Si past it, probing and emitting matches exactly as a normal in-memory hash join would. Only one partition pair needs to be resident in memory at any time.
  3. Recursive (hybrid) partitioning. If a single partition Ri is still too big to fit even after the first split (severe key skew), the engine recursively re-partitions just that pair with a different hash function — the “hybrid hash join” refinement, which only pays the extra cost where skew actually exists.
Grace hash join spill: build and probe sides partitioned by the same hash function into four partitions on disk, then processed pair by pair in phase two
Grace hash join spill: build and probe sides partitioned by the same hash function into four partitions on disk, then processed pair by pair in phase two

The reason this beats sorting the whole build side: sorting a relation that doesn’t fit in memory from scratch is O(n log n) over the entire dataset, while hash partitioning is a single linear pass to bucket the rows, after which each bucket is handled independently and cheaply. EXPLAIN (ANALYZE, BUFFERS) surfaces this directly in PostgreSQL as Batches: N on a Hash node — Batches: 1 means it fit in memory; anything higher means it spilled and partitioned.

Sargability: writing predicates an index can actually use

A predicate is sargable (“Search ARGument ABLE”) if the engine can push it straight into an index seek without first transforming every row’s value. The mechanism that breaks it: a B-tree index is ordered by the raw column value. The instant you wrap the column in a function, apply a leading wildcard, or force an implicit type conversion, the index’s ordering no longer corresponds to the thing you’re comparing — so the engine must transform every row to check the predicate, which is exactly what a sequential scan already does, except now with function-call overhead on top.

Non-sargable (index unusable)Sargable rewrite (index usable)Why
WHERE date(ts) = '2026-07-01'WHERE ts >= '2026-07-01' AND ts < '2026-07-02'date(ts) must be computed per row before comparing; the raw range on ts is a direct B-tree seek. (Alternative: build an expression index on date(ts) if the transform genuinely can’t be avoided.)
WHERE email LIKE '%@gmail.com'WHERE email LIKE 'john%' (for a prefix search), or a trigram/reverse-column index for suffix searchA leading % has no fixed prefix to seek to — the B-tree can’t narrow the range at all. A trailing wildcard ('john%') is sargable: the engine seeks to 'john' and scans forward until the prefix stops matching.
WHERE phone = 5551234 (column is varchar)WHERE phone = '5551234'Comparing a text column to a numeric literal forces an implicit cast — often applied to the column side across every row — defeating the index; matching types keeps the comparison directly seekable.

Three failure modes that don’t show up in a single EXPLAIN line

1. Cardinality error propagates up the tree

Every operator’s cost estimate is computed from its children’s row-count estimate, not the true row count. If a leaf scan under-estimates by 10× (stale statistics, a correlated predicate, an out-of-range value), that wrong number doesn’t stay local — it feeds directly into the parent join’s cost formula, which can flip the chosen join algorithm (a nested loop priced for 500 outer rows that actually receives 80,000 does 160× more probes than planned), and the join’s own now-wrong output estimate feeds the next join or sort above it. A single bad leaf estimate can silently determine the algorithm choice for an entire plan tree several levels up — which is why debugging a slow plan means walking EXPLAIN ANALYZE bottom-up to find the first node where estimated and actual rows diverge, not the top-level symptom.

2. Plan caching and parameter sniffing

A prepared statement (PREPARE p AS SELECT * FROM orders WHERE status = $1) can be planned once and reused. PostgreSQL builds a fresh “custom” plan (using the actual bound value’s real selectivity) for the first several executions, then may switch to a single cached “generic” plan built from an average/generic selectivity across all possible values. If status is skewed — 'shipped' is 90% of rows, 'refunded' is 0.1% — a generic plan tuned for the average case can be excellent for the rare value and disastrous for the common one, or vice versa, and it silently keeps running that way for every future call until re-planned. This “parameter sniffing” problem is why hot, skewed, parameterized queries sometimes need to be forced back to custom (per-call) planning rather than trusting the cache.

3. EXPLAIN ANALYZE executes the query

EXPLAIN alone only plans — it never touches data. EXPLAIN ANALYZE runs the query for real to measure actual timings and row counts, which means it also runs any side effect: a DELETE, UPDATE, or data-modifying CTE wrapped in EXPLAIN ANALYZE genuinely deletes or updates those rows. The safe habit:

BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE created_at < now() - interval '1 year';
ROLLBACK;

— real timings and row counts, zero committed changes.

Pitfalls a working engineer actually hits

Judgment layer: how a senior engineer decides

Takeaways

Related pages


Re-authored/Deepened for this guide.

🤖 Don't fully get this? Learn it with Claude

Stuck on Query Execution — Selectivity, the Cost Model, Join Algorithms, Spills & Sargability (Deep Dive)? Open Claude, copy a block below, and it'll teach you this exact concept — visually and interactively.

🎨 Explain it visually

Build the mental picture, not memorization.

I just read a lesson on **Query Execution — Selectivity, the Cost Model, Join Algorithms, Spills & Sargability (Deep Dive)** (Databases) and want to truly understand it. Explain Query Execution — Selectivity, the Cost Model, Join Algorithms, Spills & Sargability (Deep Dive) from first principles using ONE vivid real-world analogy and a visual mental model — draw it as ASCII art or a clear step-by-step diagram — with a concrete example using real numbers. Then ask me one question to check I got the mental picture, and wait for my reply. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🤔 Walk me through it (interactive)

Socratic — adapts to where you're stuck.

Teach me **Query Execution — Selectivity, the Cost Model, Join Algorithms, Spills & Sargability (Deep Dive)** interactively. Ask me ONE guiding question at a time, wait for my answer, and adapt to my confusion — build the idea with me step by step instead of explaining it all at once. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧪 Quiz me & fix my gaps

Active recall exposes what you missed.

Quiz me on **Query Execution — Selectivity, the Cost Model, Join Algorithms, Spills & Sargability (Deep Dive)** with 5 questions, easy to tricky, ONE at a time. Tell me if each answer is right; at the end, explain clearly what I got wrong and why. If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.
🧠 Make it stick

Intuition + hook + flashcards for long-term memory.

Help me remember **Query Execution — Selectivity, the Cost Model, Join Algorithms, Spills & Sargability (Deep Dive)** for the long term: give the one-sentence intuition, a memorable hook/mnemonic, a tiny worked example, and 3 active-recall flashcards (Q -> A). If you're unsure or a claim isn't standard, say so and reason from first principles instead of guessing.

📝 My notes