Query optimisation, EXPLAIN and connection pooling
A practical routine for slow queries: find them, read the plan, fix the common causes, and keep connections under control.
On the roadmap:Intermediate · step 1: Faster queries
Lesson 3 of 5 · 12 min read · quiz · exercise
A routine for slow queries
- Find the slow or heavy queries: slow query log, database statistics views, or application tracing. Rank by total time (frequency times duration), not only the slowest single query.
- Reproduce it with real parameters on realistic data size.
- Read the plan with
EXPLAIN(orEXPLAIN ANALYZEto execute and show actual times). - Change one thing (an index, a rewrite, a smaller result) and measure again.
- Guard it: add a test or an alert so it does not regress.
Reading an EXPLAIN plan
A plan is a tree of operations. Things to look for:
Figure SDA-DDV-03-1 · What to look for in a query plan · © forcsguy.com
- Scan types: sequential scan (whole table), index scan, index-only scan, bitmap scan.
- Join methods: nested loop (good for few outer rows with an indexed inner side), hash join (builds a hash of one side; good for larger sets), merge join (both sides sorted).
- Cost and rows: estimates; with
ANALYZEyou also see actual rows and time per step. The biggest actual time is where to look. - Buffers/IO show whether data came from memory or disk.
The usual suspects
| Symptom | Likely cause | Fix |
|---|---|---|
| Sequential scan on a big table for a few rows | Missing or unusable index | Add an index; avoid functions on the column |
Slow ORDER BY ... LIMIT | Sort of many rows | Index matching filter and order |
| Hundreds of tiny queries | N+1 pattern | Join or batch with IN |
SELECT * on wide rows | Reading unneeded columns | Select only needed columns; covering index |
Deep OFFSET pagination | Database skips all earlier rows | Keyset pagination: WHERE (created_at, id) < (last_created_at, last_id) ORDER BY ... LIMIT n |
LIKE '%text%' | Leading wildcard cannot use a B-tree | Full-text search or trigram index |
| Large aggregates every request | Recomputing totals | Precompute, cache, or use a summary table |
| Lock waits | Long transactions | Shorten transactions, update in a consistent order |
| Plan flips after growth | Stale statistics | Refresh statistics; review the plan |
-- Keyset pagination: stable and fast at any depth
SELECT id, created_at, total
FROM orders
WHERE customer_id = $1 AND (created_at, id) < ($2, $3)
ORDER BY created_at DESC, id DESC
LIMIT 20;
The N+1 problem
// slow: 1 query for orders + 1 per order
const orders = await db.orders.list();
for (const o of orders) o.customer = await db.customers.get(o.customerId);
// better: two queries total
const orders2 = await db.orders.list();
const customers = await db.customers.getMany([...new Set(orders2.map(o => o.customerId))]);
ORMs hide this easily; watch the number of queries per request.
Before: N+1: one query per order
After: one joined query
What changed
- 51 round trips become 1
- Latency stops growing with the number of orders
- The join needs an index on the foreign key
Connection pooling
Each database connection costs memory and a server process or thread; databases cap connections (often hundreds). Opening one per request is slow and can exhaust the limit.
Figure SDA-DDV-03-2 · Connection pool · © forcsguy.com
- Size the pool from what the database can handle, not from the number of web threads. More connections is often slower past a point.
- With many application instances, the total is instances times pool size; use an external pooler (for example PgBouncer or a proxy) in front of the database for large fleets and serverless apps.
- Always return connections (use finally blocks) and set timeouts; a leaked connection slowly starves the app.
- Keep transactions short; a connection held during a slow external call is wasted.
Beyond the single query
- Cache results of expensive, stable queries (see the caching lessons).
- Read replicas take read load (watch replication lag).
- Partition large tables by time or key so queries touch less data.
- Archive old data.
- Use batch inserts and bulk loaders for large writes.
A healthy habit
Look at the top queries by total time every sprint, not only after incidents. Most gains come from a handful of queries.
Real-world reading
Related reading
- PostgreSQL: Using EXPLAINPostgreSQL Global Development Group · PostgreSQLDocumentation
- H3: Uber's Hexagonal Hierarchical Spatial IndexUber Engineering · UberCase Studies
- Backend Developer Roadmaproadmap.sh community · roadmap.shCourses
- Use The Index, LukeMarkus Winand · use-the-index-luke.comCourses
Show 6 moreShow fewer
- PostgreSQL: Introduction to MVCCPostgreSQL Global Development Group · PostgreSQLDocumentation
- Pagelets Parallelize Server-side Processing at YelpYelp engineers · YelpCase Studies
- Minimizing Read-Write MySQL Downtime at YelpYelp engineers · YelpCase Studies
- MySQL High Availability at GitHubGitHub Engineering · GitHubCase Studies
- eBay TecheBay engineers · eBayBlogs
- 30x Performance Improvements on MySQLStreamer at YelpYelp engineers · YelpCase Studies
Check your understanding
Question 1 of 3
0 correct so far
What does EXPLAIN show?
Go deeper
- PostgreSQL: Using EXPLAIN by PostgreSQL: how to read plans
Practise it: When a query gets slow
Everything below is already set up for this lesson: change the design, run the traffic, and see whether you meet the goal.
- 1The task
- 2Build and test
At 20 seconds the database slows down five times, as a slow query would. Everything waiting on it queues.
Goal: Errors under 2%, the slowest 5% under 400 ms.
Take load off the slow part: what answers without it?
Try it yourself
A page that lists 50 orders with customer names takes 4 seconds. The code loads orders, then loads each customer separately. Diagnose and fix, and say how you would verify.
Click what a strong design here would need:
More in Building blocks
© 2026 forcsguy.com. Original lesson text and diagrams. Books and sites linked above belong to their authors.