Skip to content
Your Postgres index is fine. Your planner statistics are not.
article2 min read4.2K viewsJul 23, 2026

Your Postgres index is fine. Your planner statistics are not.

A seq scan on a 40M row table is usually a correlation problem, not a missing index.

Marcus Okafor
Marcus Okafor@marcusokafor

July 23, 2026 · 2 min read

4.2K views5 comments

Someone on the team added the fifth index to events last week because a dashboard query was slow. The query got slower. Here is what was actually happening, because I think this is the single most common way people misread a plan.

The query

SELECT account_id, count(*)
FROM events
WHERE created_at >= now() - interval '7 days'
  AND kind = 'checkout_completed'
GROUP BY account_id;

There was a btree on (created_at) and a btree on (kind). The planner estimated 12,000 rows. The real answer was 2.4 million. When your row estimate is off by 200x, every choice downstream of it is wrong: it picked a nested loop, and the nested loop ran two million times.

Why the estimate was wrong

Postgres assumes column independence unless you tell it otherwise. It knew kind = 'checkout_completed' was 3% of the table and the date range was 4% of the table, so it multiplied: 0.12%. But checkout events are not spread evenly across time — we launched a new checkout flow eleven days ago and the volume tripled. The columns are correlated, and the planner had no way to know.

CREATE STATISTICS events_kind_created (dependencies, mcv)
  ON kind, created_at FROM events;
ANALYZE events;

Estimate after: 2.1M. Plan flipped to a bitmap heap scan into a hash aggregate. 31s to 900ms, no new index.

What to read in EXPLAIN first

Not the total time. Compare rows= against actual rows= at every node, top to bottom, and find the first node where they diverge by more than about 10x. Everything above that node is the planner making confident decisions on bad information. Fixing the estimate fixes the plan; adding an index to a bad estimate just gives it a new way to be wrong.

Also: EXPLAIN (ANALYZE, BUFFERS), always. Shared hit versus read tells you whether you have a plan problem or a memory problem, and those have completely different fixes.

Written by

Marcus Okafor

Marcus Okafor

Backend engineer who ended up as the person the team pages when a query goes from 30ms to 30s. Postgres, Go, and a long-running grudge against ORMs that hide the plan from you. I like partitioning, EXPLAIN (ANALYZE, BUFFERS), and migrations that can be rolled back at 2am.

5 Comments

Sign in to join the discussion

Diego Alvarez
Diego Alvarez@diegoalvarez2mo ago

Comparing rows= against actual rows= at every node is the most useful thing I have read about EXPLAIN. I have been reading plans top-down and staring at the timings, which tells you where it hurt and never why.

0
Marcus Okafor
Marcus Okafor@marcusokafor2mo ago

Timings tell you where it hurt. Estimates tell you why it chose to hurt there.

0
Sana Qureshi
Sana Qureshi@sanaqureshi2mo ago

Extended statistics fixed a query for us on (tenant_id, created_at), correlated because tenants onboard in cohorts. Worth adding that the MCV list goes stale in the other direction after a big data shift, so it needs a re-ANALYZE on the same schedule as the thing that made it correlated.

0
Lena Fischer
Lena Fischer@lenafischer2mo ago

Outsider question: is there a downside to creating extended statistics on every correlated pair you can think of?

0
Marcus Okafor
Marcus Okafor@marcusokafor2mo ago

Not an outsider question at all. The cost is at ANALYZE time and in a little extra planning work per query, and MCV lists are capped, so on high-cardinality pairs you buy much less than you hoped. We add them where a plan is provably wrong, not pre-emptively — same reason we do not add indexes pre-emptively.

0