Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

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.

  • Advanced
  • 35 minutes
  • Examples run with sql.js 1.14.2, Python 3.14.8, Node.js 24.21.0, PostgreSQL 18.6 and MySQL 8.4.11
  • By MySmartCoPilot
Free intro, Pro: 9 of 35 minutes free
Free: The loop, and where each engine keeps the evidence. With a pass: the other 8 sections and 3 interview answers, about 26 minutes.

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

A loop of six steps: measure, read the plan, write one hypothesis, change one thing, measure again, keep or undo.1. Measure the workload:which statements cost themost in total?2. Read the plan of thecostliest one3. Write down onehypothesis4. Change one thing:an index, a rewriteor a setting5. Measure the sameworkload again6. Same results andfaster? Keep it.Otherwise, undo it.next statement

The tuning loop, one change at a time

Text description of the diagram

The diagram is a loop of six steps.

  1. Measure the workload: find the statements that cost the most in total.
  2. Read the plan of the costliest one.
  3. Write down one hypothesis about why it is slow.
  4. Change one thing: an index, a rewrite of the query or a setting.
  5. Measure the same workload again.
  6. 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:

One turn of the loop: plan, one change, plan again, same result SQL · sqlite/one_change.sql
-- 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

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, .eqp and .expert commands 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.

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

Related tools

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.