Why Postgres stops using your index at 52M rows
8.2s→340ms
The index existed and the query used its leading column. The planner's row estimate was off by four orders of magnitude, so it costed the index scan higher than reading all 52 million rows.
Raising the statistics target on tenant_id and re-running ANALYZE brought the estimate back in line. No new index, no schema change, no bigger instance.
Reproducible on PostgreSQL 16.4, db.m6g.large, from the seed script in the writeup. Synthetic data, not a client engagement.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, payload FROM events
WHERE tenant_id = 4471
AND created_at >= now() - interval '7 days';
Seq Scan on events (cost=0.00..1984322.00 rows=1 width=126)
(actual time=1.204..8188.417 rows=3184 loops=1)
Filter: ((tenant_id = 4471) AND (created_at >= now() - '7 days'::interval))
Rows Removed by Filter: 51996816
Buffers: shared hit=912 read=1443410
Planning Time: 0.214 ms
Execution Time: 8188.902 msIndex Scan using events_tenant_created_idx on events
(cost=0.56..3612.44 rows=3105 width=126)
(actual time=0.061..338.902 rows=3184 loops=1)
Index Cond: ((tenant_id = 4471) AND (created_at >= now() - '7 days'::interval))
Buffers: shared hit=946 read=2588
Planning Time: 0.188 ms
Execution Time: 339.612 ms