SQL Tuning
Most slow databases are slow because of a handful of queries. Learn to find them, read query plans, and fix the usual suspects: missing indexes, N+1 queries, SELECT *, deep OFFSET pagination and too many connections.
In a nutshell
- Measure first: find the queries that take the most total time, then read their plans with
EXPLAIN ANALYZE. - Fix N+1 queries by loading related rows in one query (
IN (…)or a join). - Select only the columns you need, and use keyset pagination instead of deep
OFFSET. - Use a connection pool: opening a connection per request is slow and can exhaust the server.
Find the slow queries
Tune by evidence. Turn on the slow query log (MySQL) or pg_stat_statements (PostgreSQL) and sort by total time (calls × average time): a 5 ms query run a million times an hour matters more than a 2-second report run once a day.
Read the real plan, with timings
EXPLAIN ANALYZE
SELECT id, total FROM orders
WHERE customer_id = 7 AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;
-- Look for: sequential scans on big tables, a sort of many rows,
-- estimated rows far from actual rows (stale statistics).The N+1 problem
An ORM loads 50 orders, then lazily loads each order's customer: 1 + 50 queries. Each is fast, but 51 network round trips add up to a slow page, and under load they hammer the database.
N+1 vs one batched query
All steps Same data, 51 round trips vs 2.
- One query loads the 50 orders.
- Then the code touches order 1's customer, triggering another query.
- And order 2's, and so on.
- 48 more round trips follow: 51 queries in total.
- The fix: collect the customer IDs and load them all in one query (or join).
Diagram as text
- One query loads the 50 orders.
- Then the code touches order 1's customer, triggering another query.
- And order 2's, and so on.
- 48 more round trips follow: 51 queries in total.
- The fix: collect the customer IDs and load them all in one query (or join).
Two queries instead of fifty-one
SELECT id, customer_id, total FROM orders WHERE status = 'paid' LIMIT 50;
SELECT id, name FROM customers WHERE id IN (7, 12, 31, 44);
-- Or one query with a JOIN. ORMs call this eager loading.Pagination without OFFSET
OFFSET 100000 LIMIT 20 makes the database produce and throw away 100,000 rows to return 20, so page 5,000 is far slower than page 1. Keyset pagination remembers where the last page ended and seeks straight there with an index.
OFFSET vs keyset (seek) pagination
-- Slow on deep pages
SELECT id, title FROM posts ORDER BY id DESC LIMIT 20 OFFSET 100000;
-- Fast on every page: continue after the last id the client saw
SELECT id, title FROM posts WHERE id < 48213 ORDER BY id DESC LIMIT 20;More quick wins
- Select only needed columns:
SELECT *drags wide text columns over the network and prevents covering indexes. - Batch writes: one INSERT with 500 rows beats 500 single inserts.
- Connection pooling: reuse a small pool of connections (PgBouncer, HikariCP); each database connection costs memory on the server.
- Right types: integers for IDs, proper date types, and the same type on both sides of a join so indexes can be used.
- Keep statistics fresh: the planner chooses plans from table statistics (
ANALYZE). - Archive old data: a table that only grows gets slower to scan, index and back up.
- Cache repeated, rarely changing results, and move reporting to a replica.
Common mistakes
- Optimising queries by guesswork instead of plans and measurements.
- Lazy-loading relations in a loop (N+1).
- Deep OFFSET pagination on large tables.
- Opening a new database connection on every request.
Interview questions
Try answering out loud before you open each one.
A page makes hundreds of database queries. What is likely wrong?
An N+1 pattern: a list is loaded, then each item lazily loads related data. Fix it with eager loading (a JOIN or a second query with WHERE id IN (…)), and add a test or query counter so it does not come back.
How do you paginate a feed with millions of rows?
Keyset pagination: order by an indexed, unique key (or created_at plus id), and ask for rows after the last seen key. Each page is an index seek, so performance does not degrade with depth. Return an opaque cursor to clients.
Check yourself
Related topics
Further reading
- PostgreSQL documentation: Using EXPLAIN PostgreSQL
- We need tool support for keyset pagination Markus Winand
Your notes
Saved in this browser as you type.
Finished this topic?
Mark it read to track your progress, then carry on with the next one.