David Gaitán
← Back to blog
Postgres

The Postgres index that looked right and never got used

· 4 min read

I added a composite index on (tenant_id, created_at) for a query that filtered by both columns constantly. It should have been an easy win. EXPLAIN ANALYZE said otherwise — a sequential scan, every time.

The query didn't match the index shape

The filter was WHERE tenant_id = $1 AND created_at::date = $2. That cast on created_at was the whole problem — wrapping an indexed column in a function makes it opaque to the planner unless you index the expression itself, not just the column. The fix was either a matching expression index or, better, rewriting the filter as a range (created_at >= $2 AND created_at < $2 + interval '1 day') so the plain composite index applied directly.

Column order matters more than it seems

Postgres can use a leading prefix of a composite index, not an arbitrary subset. (tenant_id, created_at) serves queries filtering on tenant_id alone or tenant_id AND created_at — but does nothing for a query that filters on created_at alone. Get the order wrong and the index sits there, correct in isolation, unused in practice.

Always read the actual plan

EXPLAIN shows what the planner intends to do; EXPLAIN ANALYZE shows what it actually did, with real timings and row counts. The gap between the two — an estimated 40 rows versus an actual 400,000 — is usually where the real story is. I've stopped trusting an index exists just because I remember creating it.

Have a project in mind?

I'm always open to hearing about interesting backend work.