Skip to content
article

The index that made our query slower, and other things EXPLAIN ANALYZE told me

A Postgres war story from a shipments table with 380 million rows

Bram Verhoeven@bram_on_a_bike

July 12, 2026 · 3 min read

Short version: someone added an index "because we order by that column", and the dispatcher dashboard went from 40 ms to 9 seconds for our smallest depots. The someone was me. I found out on the bike home, which is 14 km, which is enough time to stop being angry at yourself.

The query

SELECT id, reference, created_at
FROM shipments
WHERE depot_id = 42
  AND status = 'pending'
ORDER BY created_at DESC
LIMIT 50;

Dispatchers open this page all day. 380 million rows in shipments, around 2% of them pending at any moment. Before my change there was an index on depot_id. I added one on created_at.

What the plan said

Always EXPLAIN (ANALYZE, BUFFERS). Plain EXPLAIN gives you the planner's guesses. ANALYZE runs the query and shows what actually happened. BUFFERS shows how many pages came from memory and how many from disk.

Limit  (cost=0.57..1423.10 rows=50 width=40) (actual time=8712.334..9104.551 rows=50 loops=1)
  Buffers: shared hit=181220 read=402113
  ->  Index Scan Backward using shipments_created_at_idx on shipments
        (cost=0.57..2843391.02 rows=99941 width=40) (actual time=8712.331..9104.512 rows=50 loops=1)
        Filter: ((depot_id = 42) AND (status = 'pending'::text))
        Rows Removed by Filter: 4812330
        Buffers: shared hit=181220 read=402113
Planning Time: 0.311 ms
Execution Time: 9104.602 ms

Read it from the inside out. Postgres walked my new index backwards, newest first, and checked every row against the filter until it had 50 matches. It removed 4.8 million rows to find 50. Why did the planner choose that? Because of the LIMIT. It assumes matching rows are spread evenly over time, so "walk by date and stop after 50" looks cheap. For a big depot that is true. Depot 42 is small and its pending shipments are old, so the walk goes very far.

The classic trap: ORDER BY + LIMIT + a selective filter on other columns.

The fix

CREATE INDEX CONCURRENTLY shipments_depot_pending_created_idx
    ON shipments (depot_id, created_at DESC)
    WHERE status = 'pending';

A composite index that matches the filter and the sort, and partial, so it only contains the 2% of rows that are pending. Small, hot, always in memory.

Limit  (cost=0.43..61.20 rows=50 width=40) (actual time=0.041..0.198 rows=50 loops=1)
  Buffers: shared hit=54
  ->  Index Scan using shipments_depot_pending_created_idx on shipments
        (cost=0.43..4120.77 rows=3390 width=40) (actual time=0.039..0.187 rows=50 loops=1)
        Index Cond: (depot_id = 42)
        Buffers: shared hit=54
Planning Time: 0.402 ms
Execution Time: 0.231 ms

54 buffers instead of 583,000. Then I dropped my created_at index, after checking nothing else used it:

SELECT relname, indexrelname, idx_scan,
       pg_size_pretty(pg_relation_size(indexrelid)) AS size
FROM pg_stat_user_indexes
WHERE relname = 'shipments'
ORDER BY idx_scan;

Things I check every time now

  1. Estimated rows vs actual rows. If they differ by 100x, the planner is working from bad information. Fix the statistics before you fix the query.
  2. Correlated columns. The planner assumes depot_id and status are independent. They are not. Extended statistics help: CREATE STATISTICS shipments_depot_status (dependencies) ON depot_id, status FROM shipments; and then ANALYZE shipments;
  3. Test on real data. A staging copy with evenly generated rows will happily show you the fast plan. Production has skew. Our three biggest depots have 60% of all rows.
  4. EXPLAIN ANALYZE executes the query. For an UPDATE or DELETE, wrap it: BEGIN; EXPLAIN ANALYZE ...; ROLLBACK;
  5. Index for the query, not for the column.

For reading big plans I paste them into explain.dalibo.com or explain.depesz.com. Both are free and both are better at this than my eyes at 18:00.

Written by

Bram Verhoeven

Staff engineer at a logistics scale-up in Utrecht. Distributed systems, Kotlin, Postgres. 14 km by bike each way, rain or not. Boring technology is a feature.

Get the best of Dynamo Geeks weekly

One email each Monday with the most useful posts. No spam; unsubscribe in one click.

Comments

13

Sign in to join the conversation.

Sign in to comment
Alasdair Reid@onprem_or_die
an index that made it slower. aye, welcome to databases son. at least ye didnae solve it by throwing a bigger RDS instance at it like the last three contractors i met
6 net votes
Bram Verhoeven@bram_on_a_bike
We considered it. The bigger instance would have made it 7 seconds instead of 9. Not a fix.
6 net votes
Rafiul Islam@rafi_dhaka
Actually this is exactly the trap with LIMIT. Planner thinks matching rows are spread evenly so walking the date index looks cheap. Brother, the CREATE STATISTICS tip is underrated, we fixed a similar one on a Node service last year with dependencies stats on (tenant_id, status).
9 net votes
Agnieszka Zielińska@agnieszka_z
Had same problem with Hibernate, the index on created_at was added by migration tool from entity annotation. Partial index fixed it. Thanks for posting the plans in full, most posts cut them.
7 net votes
Lindiwe Mokoena@loadshedding_lindi
'Rows Removed by Filter: 4812330' is going on a t-shirt. Ja no, we had nearly this exact query on a transactions table at month end. Lekker writeup Bram.
5 net votes
Caio Nascimento@caio_backend
Index for the query, not for the column. Printing this and gluing on the monitor of our DBA kkkkk
3 net votes
Danny Cho@danny_cho_la
Saving this for the next person who says 'just add an index'. The bit about EXPLAIN ANALYZE executing the query deserves to be in bold. I watched someone EXPLAIN ANALYZE a DELETE on prod in 2019.
5 net votes
Bram Verhoeven@bram_on_a_bike
Did it come back?
2 net votes
Danny Cho@danny_cho_la
The rows did not. 🙃
2 net votes
Nurul Aisyah@nurul_sql
alamak the depot 42 one is literally my life but with branch_id. confirm saving this. one question: CONCURRENTLY can run inside a transaction or not? our migration tool wrap everything in transaction lah
8 net votes
Bram Verhoeven@bram_on_a_bike
No. CREATE INDEX CONCURRENTLY cannot run inside a transaction block. Most migration tools have a flag to disable the transaction for one migration. Use it for exactly this one.
6 net votes
Sai Teja Reddy@teja_dataeng
super anna, partial index trick chala bagundi. tried to do same in redshift then remembered redshift has no indexes, only sort keys lol
6 net votes
Lin Wei@linwei
late but good one. one more: if depot distribution is very skewed check pg_stats most_common_vals for depot_id, planner use it for estimate. we had 3 big warehouse = 70% of rows, same story
7 net votes