What is the process for tuning a slow query?
Five steps, and the order matters because each one tells you where to look next.
First, measure. Get the actual execution time and the actual row counts, not the estimate. EXPLAIN tells you what the engine intends to do; EXPLAIN ANALYZE, in PostgreSQL and MySQL, runs the query and reports what actually happened. The gap between estimated and actual rows is the single most informative signal you will get, because it means the planner is working from wrong information and any plan it picks is suspect.
Second, read the plan and find the expensive node. Look for a full table scan where you expected an index seek, a sort of far more rows than the query returns, or a nested loop executing far more times than the row count justifies. In SQLite the equivalent is EXPLAIN QUERY PLAN, which reports SCAN and SEARCH operations and which index was used.
Third, identify which part of the query is causing the problem. Sometimes it is the predicate that is not using an index, sometimes it is the join order, sometimes it is the sheer number of rows the query has to aggregate. Fix the biggest thing first — an index on a column that narrows the scan by a factor of a million is worth more than every other optimisation combined.
Fourth, make the smallest change that addresses it. Usually that is adding an index, but sometimes it is rewriting the query: replacing a correlated subquery with a join, pulling an aggregate into a derived table so it is computed once, or splitting a query in two. Add the index, re-measure, and confirm the plan changed.
Fifth, check the whole query again afterwards, because an index on one part of a query can expose a new bottleneck in another part. And check the write side — if the table is write-heavy, an index that makes the read fast but the writes slow may not be a net win.
If the plan already looks good and the query is still slow, the problem is not the plan. It is that the query is asking for too much: it returns far more rows than the application needs, it aggregates data it never uses, or it does the same work repeatedly. At that point the fix is to change what the query computes, not how it is executed.
Here is the same thing as runnable code:
CREATE TABLE orders (
id INTEGER PRIMARY KEY,
customer_id INTEGER NOT NULL,
amount DECIMAL(10, 2) NOT NULL,
status VARCHAR(20) NOT NULL
);
INSERT INTO orders (customer_id, amount, status) VALUES
(1, 120.00, 'shipped'), (1, 340.50, 'pending'), (2, 75.25, 'shipped'),
(2, 610.00, 'pending'), (3, 40.00, 'shipped'), (1, 90.00, 'pending');
-- The plan before any index exists.
EXPLAIN QUERY PLAN
SELECT id, amount FROM orders WHERE customer_id = 1 AND status = 'paid';
CREATE INDEX idx_orders_cust_status ON orders (customer_id, status);
-- The plan after.
EXPLAIN QUERY PLAN
SELECT id, amount FROM orders WHERE customer_id = 1 AND status = 'paid';
SELECT id, amount FROM orders WHERE customer_id = 1 AND status = 'paid' ORDER BY id;