Slow PostgreSQL query on a 40M row table

Back to list2026-08-12archived
You

This query takes 12 seconds on a table with 40M rows:

SELECT user_id, count(*)
FROM events
WHERE created_at > now() - interval '30 days'
GROUP BY user_id
ORDER BY count(*) DESC
LIMIT 50;

What should I look at first?

ChatGPT

Run EXPLAIN (ANALYZE, BUFFERS) first. With 40M rows and a 30 day window, the planner usually falls back to a sequential scan unless there is an index that covers both the filter and the grouping.

A partial index tends to help here:

CREATE INDEX CONCURRENTLY events_recent_user_idx
  ON events (user_id)
  WHERE created_at > '2026-01-01';

The caveat is that a hardcoded date makes the index go stale, so most teams schedule a rebuild or use a rolling partition instead.

Tool steps: 3 (reasoning summary, call to python, execution output)
reasoning summary
Checked the query plan, then compared index options.
call to python
rows = run_sql("EXPLAIN (ANALYZE) SELECT ...")
print(rows[:3])
execution output
Seq Scan on events  (cost=0.00..812431.00 rows=41203122 width=16)
  Rows Removed by Filter: 38996878
Planning Time: 0.214 ms
Execution Time: 11842.663 ms
You

Partitioning by month is on the table. Is it worth it at this size?

ChatGPT

At 40M rows the win comes less from scan speed and more from maintenance: vacuum, index rebuilds and retention deletes all become per-partition operations. Dropping a partition is instant; deleting 3M rows is not.

Rule of thumb: partition when retention is time based, when old data is rarely queried, or when a single index no longer fits in shared buffers.

You
latency-before-after.png
ChatGPT

The drop from 310 ms to about 70 ms after the index matches what the plan predicted. The remaining time is mostly the sort, which you can avoid by pushing the limit into a lateral join if it ever becomes the bottleneck.

You

One more thing: autovacuum keeps falling behind on this table.

ChatGPT

Two knobs matter at this size. Lower the scale factor so vacuum triggers on a fixed fraction rather than a percentage of a huge table:

ALTER TABLE events SET (
  autovacuum_vacuum_scale_factor = 0.02,
  autovacuum_vacuum_cost_limit = 2000
);

Then check pg_stat_progress_vacuum during a run. If it spends most of its time in vacuuming indexes, the index count is the real problem, not the settings.