Thabo Mokoena
@thabo_mokoena • 1 months ago
Paste a slow query, its plan and indexes. Get the costliest step named, rewrites and index changes ranked by risk, and a same-results check.
databasequeryschemaplan{{database}}{{query}}{{schema}}{{plan}}database: PostgreSQL 16
query:
```sql
SELECT o.id, o.created_at, o.total, c.email
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE lower(o.status) = 'paid'
AND date(o.created_at) >= '2026-09-01'
ORDER BY o.created_at DESC
LIMIT 50;
```
schema: orders: 18M rows; created_at timestamptz; status text (values seen: paid, pending, refunded, failed). Indexes: PK id, btree (customer_id), btree (created_at). customers: 900k rows, PK id.
plan:
```
Limit (actual time=6421.3..6421.4 rows=50 loops=1)
-> Sort (actual time=6421.3..6421.3 rows=50 loops=1)
Sort Key: o.created_at DESC
Sort Method: top-N heapsort Memory: 32kB
-> Hash Join (actual time=402.1..6188.9 rows=412877 loops=1)
Hash Cond: (o.customer_id = c.id)
-> Seq Scan on orders o (actual time=0.03..5410.7 rows=412877 loops=1)
Filter: ((lower(status) = 'paid'::text) AND (date(created_at) >= '2026-09-01'::date))
Rows Removed by Filter: 17587123
-> Hash (actual time=398.2..398.2 rows=900000 loops=1)
Planning Time: 0.4 ms
Execution Time: 6423.0 ms
`````lower(status) and date(created_at) can't use the plain btree on created_at, and date() on a timestamptz isn't even indexable, because its result depends on the session's TimeZone. With no usable index, the planner can't walk created_at backwards and stop after 50 rows. It has to filter everything, join everything, then sort.`sql`created_at index backwards, filtering on status as it goes. That's fast while paid orders are common among recent rows.`sql`Limit → Nested Loop → Index Scan using orders_paid_created_at_idx plus 50 PK lookups on customers. No hash, no sort. Storage is roughly proportional to the number of paid rows. CONCURRENTLY avoids blocking writes while it builds.date(created_at) >= '2026-09-01' means "on or after midnight 1 Sept in the session TimeZone". '2026-09-01'::timestamptz is also interpreted in the session TimeZone, so the two are equivalent as long as both run in the same session setting. Don't hard-code +00 unless the old query always ran in UTC.lower() is only safe if no row has 'Paid' or 'PAID'. Check with SELECT DISTINCT status FROM orders;. Better, add CHECK (status = lower(status)) so it stays true.status or created_at were excluded before and are still excluded.created_at values can come back in a different order at the LIMIT boundary. Add , o.id DESC if order must be stable.`sql`rows=50 at the top, and execution in single-digit milliseconds. Compare shared read buffers against the old plan.pg_stat_statements and wait-event data for those. Without EXPLAIN ANALYZE output (only the query), expect educated guesses. Always test index builds on a staging copy of production-sized data.