open atlas
↑ Back to track
SQL & PostgreSQL, deep SQL · 08 · 02

The cost-based planner

The planner prices every candidate scan and join by an estimated cost and picks the cheapest. Postgres ships no query hints on purpose — you tune the inputs, not the output.

SQL Middle ◷ 15 min
Level
FoundationsJuniorMiddleSenior

A new engineer, fresh from Oracle, opens a ticket: “Postgres won’t let me add an index hint. How do I force it to use my_index?” The answer — “you can’t, and that’s deliberate” — sounds insane until you’ve watched a hinted Oracle query stay locked to a plan that became catastrophic after the data tripled. Postgres made a bet 25 years ago: tune the planner’s inputs, never override its output. This lesson is how that bet pays off and where it bites.

How it chooses: cost, not rules

When a “wrong plan” bites you in production, the answer is almost never a new SQL syntax — it’s understanding which number the planner got wrong. The planner takes your analyzed query and enumerates candidate physical plans — different scan methods, different join algorithms, different join orders — then assigns each a numeric cost and picks the lowest. The databases track’s execution-plans chapter derives the cost model and the scan/join taxonomy in depth; here the point is the decision loop the SQL author sees from outside.

Cost is built from estimated row counts (from statistics, lesson 03) multiplied by per-operation constants. The defaults that matter most: seq_page_cost = 1.0 (read one page sequentially), random_page_cost = 4.0 (read one page randomly), cpu_tuple_cost = 0.01 (process one row). An index scan does random page reads; a sequential scan does cheap sequential ones. So the planner’s verdict on “index or seq scan?” is really arithmetic: how many rows match × random cost versus whole table × sequential cost.

Scan choice for WHERE status = 'shipped':

  • Few matching rows → Index Scan (cheap to jump straight to them).
  • Many matching rows → Seq Scan (random I/O per row would cost more than reading the whole table in order). This is correct, not a bug — a Seq Scan on a non-selective filter beats an index.
  • A middling fraction → Bitmap Index Scan (collect matching page pointers, sort them, read pages in physical order — index precision without random thrashing).

Join choice for orders ⋈ line_items:

  • Tiny outer side → Nested Loop (for each outer row, probe an index on the inner — great when “few × indexed lookup”).
  • Two large unsorted sides → Hash Join (build a hash table on the smaller, stream the larger past it).
  • Two already-sorted inputs → Merge Join (walk both in lockstep).
-- Watch the scan flip as selectivity changes:
EXPLAIN SELECT * FROM orders WHERE status = 'cancelled';  -- rare → Index Scan
EXPLAIN SELECT * FROM orders WHERE status = 'placed';     -- common → Seq Scan

Why Postgres has no hints

Oracle and SQL Server let you pin a plan with a hint. Postgres refuses, and the reasoning is durable: a hint is a frozen decision against a moving dataset. The query you hint to “use the index” today is the query that does 50M random reads next year when the table grew and that index stopped being selective. The planner, fed fresh statistics, would have switched to a Seq Scan on its own. A hint forbids that adaptation.

So the senior workflow is to fix the inputs so the planner reaches the right plan itself:

  1. Statistics — run ANALYZE, or raise default_statistics_target / add extended statistics (lesson 03). Most “wrong plans” are wrong estimates.
  2. Indexes — add or drop an index to change which candidates exist. The planner can only pick from what’s available.
  3. Config — on SSD/NVMe, random_page_cost = 4.0 is a spinning-disk default that wildly over-penalizes index scans; lowering it to 1.1 is the single most common production tweak. effective_cache_size tells the planner how much OS cache it can assume.
  4. Rewrite the query — sometimes a LATERAL, a EXISTS instead of IN, or splitting a query changes the candidate space for the better.
  5. join_collapse_limit — past ~8 joined tables the planner stops exhaustively reordering joins (the search space is factorial) and trusts your written order. Raising it lets it optimize bigger joins at planning-time cost.

Together these inputs — statistics, indexes, cost constants, and query shape — are everything the planner has to work with. Fix any one of them correctly and the planner re-derives a good plan on its own; skip to fixing the output instead, and you win a brittle frozen plan that will hurt you when the data changes.

Common mistake

The most common self-inflicted bad plan: leaving random_page_cost at 4.0 on NVMe storage. On modern SSDs a random page read is only marginally more expensive than a sequential one, so 4.0 tells the planner that index scans cost ~4× what they really do — and it dodges perfectly good indexes in favor of Seq Scans. Teams stare at “why won’t it use my index?” for hours. The fix is one line: ALTER SYSTEM SET random_page_cost = 1.1; then SELECT pg_reload_conf();. This is not a hint — it corrects a price, and the planner re-derives every plan correctly. (The genuine pg_hint_plan extension exists for the rare case you truly need to pin a plan, but reach for it last, never first.)

Quiz

EXPLAIN shows a Seq Scan for WHERE status = 'placed', which matches 40% of a large table. Is this a bug?

Quiz

Your DB runs on NVMe SSD but the planner keeps choosing Seq Scans over good indexes. Best first config change?

Complete the analogy

Fill in the blank: because Postgres has no query hints, you cannot override the planner's _______; instead you tune its inputs — statistics, indexes, and cost constants — so it reaches the right plan itself.

Recall before you leave
  1. 01
    How does the planner decide between an Index Scan and a Seq Scan?
  2. 02
    Why does Postgres deliberately have no query hints, and what do you tune instead?
  3. 03
    Your DB is on NVMe SSD and the planner avoids good indexes — what is the likely cause and one-line fix?
Recap

The cost-based planner is a pricing engine: from your query it enumerates candidate physical plans — Seq/Index/Bitmap scans, Nested-Loop/Hash/Merge joins, and join orders — assigns each an estimated cost (rows × per-operation constants like seq_page_cost, random_page_cost, cpu_tuple_cost), and runs the cheapest. A Seq Scan on a non-selective filter is the right answer, not a failure. Because Postgres ships no query hints by design — a hint freezes a decision against data that keeps moving — you tune the planner’s inputs instead: refresh statistics (most bad plans are bad estimates, lesson 03), add or drop indexes to change the candidate set, fix cost constants (lowering random_page_cost on SSD is the classic win), rewrite the query, or raise join_collapse_limit for many-table joins. The planner owns the pick; you own the prices, the candidates, and the estimates that feed it. Now when you see a plan that surprises you, the question to ask first is not “how do I force it?” but “which input did I get wrong?”

Practice

Start at the top. Tasks go easiest → hardest: recall a fact, apply it to a case, then a senior-level stretch. Open one, attempt it, then reveal.

recallapplystretch0 of 7 done
Connected lessons

Something unclear?

Ask a question about this lesson. Questions are anonymous and go straight to the author to make the lesson better.

shortcuts expand
search
K
prev piece
k
next piece
j
cycle tier
t
this menu
?
sources3
expand
  1. 01
  2. 02
  3. 03

Trademarks belong to their respective owners. Editorial reference only.