Lesson 0008 · The Planner

Why the planner "ignores" your index

It never ignores it — the planner is cost-based, so an index is used only when it's estimated cheaper than the alternatives. Learn when a Seq Scan is the right answer, and the handful of times the estimate is genuinely wrong.

~11 minWarm-up recall + quiz + MySQL contrastCheat sheet: here

Warm-up: recall 0006 & 0005 first

Closed book. One planner thread, one vocabulary fix from last review.

Reading EXPLAIN ANALYZE, the single most useful check is:

A gap between estimated and actual rows means the planner worked from bad numbers — and this lesson is all about how that gap makes it "ignore" your index.

A SERIALIZABLE transaction aborts with read/write dependencies. That is a:

It's a serialization failure (SQLSTATE 40001) — SSI aborting to preserve correctness — NOT a deadlock (40P01, a lock cycle). Both are fixed by retrying, but they're different events.

"Why isn't Postgres using my index?" is the wrong question, and fixing the wrong question wastes hours. Postgres has a cost-based planner: for each query it estimates the cost of every candidate plan — sequential scan, index scan, bitmap scan — and runs the cheapest. Your index is never "ignored." It simply lost a cost comparison. The right question is: did it lose fairly?

Why a Seq Scan can genuinely beat an index

Recall from 0003 that an index scan is a two-step: for each match, jump from the index into the heap. Those heap jumps are random I/O, and Postgres prices random much higher than sequential:

seq_page_cost = 1.0 ← one page in a sequential sweep random_page_cost = 4.0 ← one page fetched out of order (the default)

PostgreSQL Docs: seq_page_cost "The default is 1.0." / random_page_cost "the cost of a non-sequentially-fetched disk page. The default is 4.0."

So if your WHERE matches a large fraction of the table, an index scan means thousands of expensive random jumps, while a Seq Scan reads the whole heap in one cheap sequential pass. The planner correctly picks the sweep. The extreme, from the docs: PostgreSQL Docs: "on a table that only occupies one disk page, you'll nearly always get a sequential scan plan whether indexes are available or not… there's no value in expending additional page reads to look at an index."

Selectivity picks the gear

It's all about what fraction of rows match:

few rows match → Index Scan (a few cheap random jumps) medium / scattered → Bitmap Heap Scan (collect locations, read heap in order) large fraction match → Seq Scan (one sequential sweep wins)

A Bitmap Heap Scan (from 0006) is the middle gear: it gathers all matching tuple locations from the index first, sorts them, then reads the heap in physical order — turning random jumps back into a semi-sequential scan. Postgres reaching for a Seq Scan or Bitmap scan on a non-selective query is the planner being right, not broken.

When the estimate is genuinely wrong — the real bugs

Now the cases worth fixing. These are where the planner would choose your index if it saw the truth. Most map straight onto MySQL lesson 0004.

1 · Stale statistics → wrong row estimate

The planner estimates selectivity from table statistics. If those are stale, it can think "most of the table matches" when really 5 rows do — and pick a Seq Scan for a highly-selective query. This is the 0006 skill in action: a big estimated-vs-actual rows gap is the tell. Fix: ANALYZE thetable; (and remember autovacuum runs ANALYZE — a table that outran autovacuum can have bad stats).

2 · A function or expression hides the column

WHERE lower(email) = 'a@x.com' cannot use a plain index on email — the planner only has an index on the raw column, not on lower(email). Same trap as MySQL's "wrapped column." Fix: index the expression: CREATE INDEX ON t (lower(email));.

3 · Type mismatch & leading wildcards

A comparison that forces a type coercion, or a LIKE '%foo' with a leading wildcard, can't use a normal B-tree — identical to MySQL. Fix the type, or use a suitable index type (e.g. trigram) for wildcard search.

4 · The cost model doesn't match your hardware

This one is Postgres-specific and the most common silent cause on modern servers. The default random_page_cost = 4.0 assumes spinning disks where random I/O is ~4× sequential. On SSDs, or a database mostly cached in RAM, random access is nearly free — so 4.0 makes every index scan look artificially expensive and the planner over-prefers Seq Scans. PostgreSQL Docs: "setting them equal makes sense if the database is entirely cached in RAM, since in that case there is no penalty for touching pages out of sequence." Common fix: lower random_page_cost toward 1.1, and set effective_cache_size to reflect real RAM — a higher value tips the planner toward indexes. PostgreSQL Docs: effective_cache_size "a higher value makes it more likely index scans will be used, a lower value makes it more likely sequential scans will be used." (default 4GB)

Diagnose, don't guess — and test with the knob, don't ship it

You can force the planner off sequential scans for one session to see what it would cost: SET enable_seqscan = off; then re-run EXPLAIN ANALYZE. If the index plan is now genuinely cheaper and faster, your costs are mis-set (random_page_cost / effective_cache_size) or your stats are stale — fix those. enable_seqscan is a diagnostic, not a setting to leave off in production — it's a sledgehammer that just penalizes one plan type.

The diagnostic loop

"my index isn't used" │ ▼ EXPLAIN (ANALYZE) the query ──▶ estimated rows ≈ actual rows? │ │ NO → stale stats → ANALYZE │ YES (estimate is honest) │ ▼ ▼ (re-check) Is the query selective? ── NO ──▶ Seq/Bitmap scan is CORRECT. Stop. │ YES ▼ Index still not chosen → costs mis-set for your hardware: lower random_page_cost, raise effective_cache_size (verify with enable_seqscan=off) │ └─ still no? → column is wrapped / type-mismatched → expression index or fix the predicate

Postgres vs MySQL — "index ignored"

CauseMySQL (lesson 0004)PostgreSQL
Low selectivityFull scan chosenSeq / Bitmap scan chosen (correct)
Wrapped columnfunc(col) skips indexSame — needs an expression index
Leading wildcard / typeIndex unusableSame
Bad estimateStale stats → wrong planStale stats → run ANALYZE
Hardware cost modelrandom_page_cost / effective_cache_size

Check yourself

From memory. Two items reach back on purpose.

Postgres picks a Seq Scan over your index. The most accurate framing is:

The planner is cost-based: it estimated the seq scan cheaper. The question is whether that estimate was fair (selectivity/hardware) or wrong (stale stats).

A query matching most of the table gets a Seq Scan because an index scan would:

Each match is a random heap jump (random_page_cost 4.0 vs seq 1.0). For a large fraction of rows, one sequential sweep beats thousands of random fetches.

On an all-SSD, heavily-cached server, indexes are under-used. The classic fix is:

The default 4.0 assumes spinning disks. On SSD/cached data random access is nearly free, so lowering random_page_cost (and raising effective_cache_size) makes index scans win their fair comparisons.

WHERE lower(email) = $1 won't use a plain index on email. Fix:

The planner only has an index on the raw column, not on lower(email). Create an expression index: CREATE INDEX ON t (lower(email)). Same trap as MySQL's wrapped column.

Estimated rows say 90% match but actually 5 rows do, and you got a Seq Scan. First move: (recall 0006)

The estimate is wrong, so the plan is wrong. Estimates come from stats gathered by ANALYZE — refresh them and the planner will see the query is selective and use the index.
Optional — when you have an instance

A lab to run later. Watch the plan change with selectivity and with the cost knob:

CREATE TABLE t (id int PRIMARY KEY, k int);
INSERT INTO t SELECT g, g % 1000 FROM generate_series(1,1000000) g;
CREATE INDEX ON t (k);
ANALYZE t;

EXPLAIN (ANALYZE) SELECT * FROM t WHERE k = 7;      -- selective (~1000 rows): index/bitmap
EXPLAIN (ANALYZE) SELECT * FROM t WHERE k < 900;    -- ~90% of rows: Seq Scan (correct)

SET enable_seqscan = off;                           -- diagnostic only
EXPLAIN (ANALYZE) SELECT * FROM t WHERE k < 900;    -- forced index plan — compare the cost/time
RESET enable_seqscan;
SET random_page_cost = 1.1;                          -- SSD-like; re-run selective queries

Bring a plan where you disagree with the planner and we'll work out who's right.

Primary source — read this next

PostgreSQL Docs — 19.7.2 Planner Cost Constants (seq_page_cost, random_page_cost, effective_cache_size), then the selectivity examples in 14.1 Using EXPLAIN. For vendor-neutral first principles on when an index helps, Markus Winand's Use The Index, Luke!