SQL (PostgreSQL, MySQL, SQLite) Module 12 – Indexes, query plans and performance
A query-tuning workflow
Find the costliest SQL statements with each engine's statistics, test one change at a time with plans, timings and result checks, and fix N+1 queries in code.
What you will learn
- Find the costliest statements with pg_stat_statements, MySQL's digests and the SQLite shell
- Test one change at a time with plans, measurements and a check of the results
- Fix N+1 query patterns from application code with a join or a batched IN list
- Recognise when an index cannot help and when a plan hint is justified
Before you start
What you’ll learn · Before you start
What you will learn
- Find the costliest statements with pg_stat_statements, MySQL's digests and the SQLite shell
- Test one change at a time with plans, measurements and a check of the results
- Fix N+1 query patterns from application code with a join or a batched IN list
- Recognise when an index cannot help and when a plan hint is justified
Before you start
On this page
A query-tuning workflow is a loop that turns “the database is slow” into one measured fix at a time: find the statements that cost the most in total, read the plan of the worst one, write down one hypothesis, change one thing, measure the same workload again, and keep the change only if the results are identical and the workload got faster. The discipline matters more than any single trick. Ranking by total cost, the time of one call times the number of calls, finds the right target, which is often a quick statement that runs a hundred thousand times a day rather than the slow report everyone complains about. One change at a time shows what helped, and comparing results catches the fix that is fast because it is wrong.
The loop, and where each engine keeps the evidence
The tuning loop, one change at a time
Text description of the diagram
The diagram is a loop of six steps.
- Measure the workload: find the statements that cost the most in total.
- Read the plan of the costliest one.
- Write down one hypothesis about why it is slow.
- Change one thing: an index, a rewrite of the query or a setting.
- Measure the same workload again.
- Decide: if the results are the same and the workload is faster, keep the change; otherwise undo it.
A dashed arrow labelled "next statement" leads from the decision back to the first step.
One turn of the loop fits in a few statements. The query is a customer’s ten latest orders, on 20,000 orders:
-- 20,000 orders of 2,000 customers. The slow query: a customer's ten latest orders.
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
placed_at TEXT NOT NULL,
amount_paise INTEGER NOT NULL
);
WITH RECURSIVE n(i) AS (SELECT 1 UNION ALL SELECT i + 1 FROM n WHERE i < 20000)
INSERT INTO orders
SELECT i, 1 + (i * 37) % 2000,
strftime('%Y-%m-%d %H:%M:%S', '2026-01-01', '+' || ((i * 7919) % 7776000) || ' seconds'),
100 + (i * 7919) % 90000
FROM n;
-- Keep the result before the change, to compare it afterwards.
CREATE TEMP TABLE before AS
SELECT id, placed_at, amount_paise FROM orders
WHERE customer_id = 42 ORDER BY placed_at DESC, id DESC LIMIT 10;
-- 1. Read the plan: every order is read, then the matches are sorted.
EXPLAIN QUERY PLAN
SELECT id, placed_at, amount_paise FROM orders
WHERE customer_id = 42 ORDER BY placed_at DESC, id DESC LIMIT 10;
-- 2. One change, from the plan: an index that finds the customer's orders already in order.
CREATE INDEX orders_customer_newest ON orders (customer_id, placed_at, id);
-- 3. Read the plan again: a search, and no sort.
EXPLAIN QUERY PLAN
SELECT id, placed_at, amount_paise FROM orders
WHERE customer_id = 42 ORDER BY placed_at DESC, id DESC LIMIT 10;
-- 4. Check that the result did not change: no row in one result and not the other, in the same order.
CREATE TEMP TABLE after AS
SELECT id, placed_at, amount_paise FROM orders
WHERE customer_id = 42 ORDER BY placed_at DESC, id DESC LIMIT 10;
SELECT (SELECT count(*) FROM (SELECT * FROM before EXCEPT SELECT * FROM after)) AS only_before,
(SELECT count(*) FROM (SELECT * FROM after EXCEPT SELECT * FROM before)) AS only_after,
(SELECT group_concat(id, ' ') FROM before) = (SELECT group_concat(id, ' ') FROM after) AS same_order; Output
┌────┬────────┬─────────┬──────────────────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼──────────────────────────────┤ │ 4 │ 0 │ 216 │ SCAN orders │ │ 19 │ 0 │ 0 │ USE TEMP B-TREE FOR ORDER BY │ └────┴────────┴─────────┴──────────────────────────────┘ ┌────┬────────┬─────────┬──────────────────────────────────────────────────────────────────┐ │ id │ parent │ notused │ detail │ ├────┼────────┼─────────┼──────────────────────────────────────────────────────────────────┤ │ 5 │ 0 │ 63 │ SEARCH orders USING INDEX orders_customer_newest (customer_id=?) │ └────┴────────┴─────────┴──────────────────────────────────────────────────────────────────┘ ┌─────────────┬────────────┬────────────┐ │ only_before │ only_after │ same_order │ ├─────────────┼────────────┼────────────┤ │ 0 │ 0 │ 1 │ └─────────────┴────────────┴────────────┘
Recorded with sql.js 1.14.2 (SQLite 3.49.1) on macOS 26 arm64. To run it yourself: sqlite3 -box :memory: < one_change.sql
Runs on this device, in your browser. The first run downloads SQLite (about 0.7 MB), which is kept for the next runs.
Your run, in this browser
Before the change, SQLite reads every order and sorts the matches in a temporary B-tree. The plan suggests the
hypothesis: an index on (customer_id, placed_at, id) would find the customer’s orders already in order. After it,
the plan is a single SEARCH with no sort, and the comparison finds no row in one result that is missing from the
other, in the same order. Write each turn down as you go: the plan before and after, the cost per call, and the check
of the results are what make a change reviewable. The measuring step needs a tool that sees the whole workload, and
each engine has its own:
- PostgreSQL’s pg_stat_statements extension keeps calls, rows, time and pages for each statement, with constants replaced by placeholders.
- PostgreSQL’s auto_explain module logs the plan of every statement slower than a threshold you choose.
- MySQL’s slow query log records statements that run longer than
long_query_time, 10 seconds unless you lower it. - MySQL’s Performance Schema groups statements into digests, each with its calls, rows examined and scans without an index.
- The SQLite shell’s
.timer,.eqpand.expertcommands time each statement, print its plan and suggest indexes.
Two problems need more than an index: N+1 queries, one query for a list and then one more for each of its rows, and plans that stay wrong, where a plan hint forces the engine’s hand as a last resort.
A tuning session on 100,000 orders, measured Pro: opens with a Pro, Premium or Ultimate pass
In this part:
- Finding the costliest statements in PostgreSQL and MySQL, with numbers
- N+1 queries from application code, counted
- When an index makes no difference
- Hints and other last resorts
- A worksheet for every change
- Practise: replace an N+1 loop with one query
- Replace an N+1 loop with one query
- Check yourself
- Interview questions
- Your PostgreSQL primary runs at 90 % CPU at peak and nobody knows why. Walk through how you find the statements responsible and decide what to fix first, within an hour.
- An API endpoint that lists 200 orders with their customers and items takes 1.5 seconds, and its log shows 401 queries per request. Explain the problem, fix it, and compare the fixes.
- A critical MySQL 8.4 query flips to a bad plan a few times a week, and a teammate proposes an optimizer hint to pin the index it should use. When is a plan hint justified, what are its risks, and what do you try first?
- Key takeaways
5 runnable examples (JavaScript, SQL, digests.py, mysql_server.py, pg_server.py, top_queries.py) · 1 exercise with sample tests · 3 interview answers · about 21 minutes
Opening this part with your pass…
This part opens with any MySmartCoPilot pass.
- Pro pass · 1 week₹79$2.99
- 30 days₹179$5.99
- 1 year₹1,699$45
One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.
Passes can’t be bought right now, so this part can’t be opened at the moment. The free part above stands on its own. If you have a pass, enter your licence key.
It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).
References
- PostgreSQL 18 Documentation: pg_stat_statements (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: auto_explain (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Using EXPLAIN (The PostgreSQL Global Development Group)
- PostgreSQL 18 Documentation: Query Planning (planner method configuration) (The PostgreSQL Global Development Group)
- PostgreSQL 19 Release Notes (pg_plan_advice) (The PostgreSQL Global Development Group)
- MySQL 8.4 Reference Manual: The Slow Query Log (Oracle Corporation)
- MySQL 8.4 Reference Manual: Performance Schema Statement Digests and Sampling (Oracle Corporation)
- MySQL 8.4 Reference Manual: Statement Summary Tables (Oracle Corporation)
- MySQL 8.4 Reference Manual: Optimizer Hints (Oracle Corporation)
- SQLite: Command Line Shell For SQLite (.timer, .eqp, .expert) (SQLite)
- SQLite: The SQLite Query Optimizer Overview (manual control of query plans) (SQLite)
- Node.js 24 Documentation: SQLite (node:sqlite) (OpenJS Foundation)
Related tools
Report a problem with this lesson
Kept only in this browser. Your Learn progress