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.
Contents
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
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
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.
Comparing
rows=againstactual 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.