MENU
The Optimizer, EXPLAIN, and Query Tuning
The optimizer decides whether a query uses the indexes of Section 3.11. This section reads, measures and corrects its plans on shop grown with a recursive CTE (Section 3.6.8) and seeded RAND(): 100,000 customers (45% in the US), 10,000 products, 500,000 orders (5% cancelled), 1,249,994 order lines and 200,000 reviews, indexed only by the declared keys. Timings come from a WSL2 machine (Ubuntu 26.04, MySQL 9.7.2, default 128 MB buffer pool); expect yours to differ.3.12.1 How the Cost-Based Optimizer Chooses a Plan
SQL says which rows you want, not how to fetch them. The cost-based optimizer first rewrites the statement (merging derived tables, turning IN subqueries into semijoins, propagating constants), then prices candidate access methods, join orders and algorithms, pruning a search that grows factorially with the tables. Its constants live in mysql.server_cost and mysql.engine_cost (on 9.7: 0.1 per row evaluated, 0.25 per page read from memory, 1.0 from disk). Row estimates come from index statistics (Section 3.11.6), index dives that count a range in the B+tree, and histograms (Section 3.12.7).
Figure 3.11: From SQL text to an executable plan, and the statistics
the cost model readsThe per-session optimizer trace records each
decision as JSON. Keep two traces and flatten their range analysis with
JSON_TABLE (Section 3.9.5):SET optimizer_trace = 'enabled=on', optimizer_trace_offset = -2, optimizer_trace_limit = 2;
SELECT COUNT(*) INTO @n FROM orders WHERE customer_id BETWEEN 1000 AND 30000 AND status = 'paid';
SELECT COUNT(*) INTO @n FROM orders WHERE customer_id BETWEEN 1000 AND 3000 AND status = 'paid';
SELECT jt.* FROM information_schema.OPTIMIZER_TRACE,
JSON_TABLE(TRACE->'$**.range_analysis', '$[*]' COLUMNS (
scan_cost DOUBLE PATH '$.table_scan.cost',
NESTED PATH '$.analyzing_range_alternatives.range_scan_alternatives[*]' COLUMNS (
range_scanned VARCHAR(30) PATH '$.ranges[0]', est_rows INT PATH '$.rows',
range_cost DOUBLE PATH '$.cost', chosen VARCHAR(5) PATH '$.chosen'))) AS jt;
Query OK, 0 rows affected (0.000 sec)
Query OK, 1 row affected (0.099 sec)
Query OK, 1 row affected (0.019 sec)
+-----------+------------------------------+----------+------------+--------+
| scan_cost | range_scanned | est_rows | range_cost | chosen |
+-----------+------------------------------+----------+------------+--------+
| 50204.7 | 1000 <= customer_id <= 30000 | 246594 | 86308.2 | false |
| 50204.7 | 1000 <= customer_id <= 3000 | 18146 | 6351.36 | true |
+-----------+------------------------------+----------+------------+--------+
2 rows in set (0.002 sec)
3.12.2 Reading EXPLAIN Output
EXPLAIN shows the plan without running the statement. Since 9.5 the default explain_format is TREE, so a plain EXPLAIN prints a tree, not the table older tutorials show. FORMAT=TRADITIONAL gives the table; cut keeps nine of its twelve columns so it fits a terminal:q="SELECT o.id, o.ordered_at, c.name FROM customers AS c JOIN orders AS o
ON o.customer_id = c.id WHERE c.country = 'SE' AND o.status = 'cancelled'"
sudo mysql shop -Nre "EXPLAIN $q"
sudo mysql shop -e "EXPLAIN FORMAT=TRADITIONAL $q" | cut -f3,5-12 | column -t -s $'\t'
-> Nested loop inner join (cost=27369 rows=12261)
-> Filter: (c.country = 'SE') (cost=10204 rows=9730)
-> Table scan on c (cost=10204 rows=97302)
-> Filter: (o.`status` = 'cancelled') (cost=1.26 rows=1.26)
-> Index lookup on o using customer_id (customer_id = c.id) (cost=1.26 rows=5.04)
table type possible_keys key key_len ref rows filtered Extra
c ALL PRIMARY NULL NULL NULL 97302 10.00 Using where
o ref customer_id customer_id 4 shop.c.id 5 25.00 Using where
3.12.3 The type Column and Access Methods
type, how MySQL finds a table's rows, is the first column to check. This function prints five columns of the traditional plan (table, type, key, rows, Extra):plan() { sudo mysql shop -N -e "EXPLAIN FORMAT=TRADITIONAL $1" | cut -f3,5,7,10,12; }
{
plan "SELECT * FROM orders WHERE id = 42"
plan "SELECT * FROM order_items AS oi JOIN orders AS o ON o.id = oi.order_id
WHERE oi.product_id = 7"
plan "SELECT * FROM orders WHERE customer_id BETWEEN 10 AND 20"
plan "SELECT * FROM orders WHERE customer_id = 5 OR id = 9"
plan "SELECT COUNT(*) FROM orders"
plan "SELECT * FROM orders WHERE status = 'pending'"
} | column -t -s $'\t'
orders const PRIMARY 1 NULL
oi ref product_id 52 NULL
o eq_ref PRIMARY 1 NULL
orders range customer_id 62 Using index condition
orders index_merge customer_id,PRIMARY 7 Using union(customer_id,PRIMARY); Using where
orders index customer_id 499223 Using index
orders ALL NULL 499223 Using where
3.12.4 The Extra Column
Extra reports work beyond the access method and is often the most actionable column:plan() { sudo mysql shop -N -e "EXPLAIN FORMAT=TRADITIONAL $1" | cut -f3,5,7,10,12; }
{
plan "SELECT customer_id FROM orders WHERE customer_id = 5"
plan "SELECT * FROM customers WHERE email LIKE 'user12%' AND name LIKE '%9'"
plan "SELECT status, COUNT(*) FROM orders GROUP BY status ORDER BY COUNT(*) DESC"
plan "SELECT MAX(id) FROM orders"
plan "SELECT COUNT(*) FROM reviews AS r JOIN orders AS o ON o.ordered_at = r.created_at"
} | column -t -s $'\t'
orders ref customer_id 6 Using index
customers range email 1111 Using index condition; Using where
orders ALL NULL 499223 Using temporary; Using filesort
NULL NULL NULL NULL Select tables optimized away
r ALL NULL 199366 NULL
o ALL NULL 499223 Using where; Using join buffer (hash join)
Using index: the index covers the query (Section 3.11.5), so no row is read.
Using index condition: InnoDB tests the email prefix inside the index before fetching rows (index condition pushdown). Using where: the server filters rows, here on name LIKE '%9'.
Using temporary; Using filesort: grouping built an internal temporary table and ORDER BY needed a sort (in memory while sort_buffer_size suffices, Section 3.13.5). Both on a large ALL scan usually mean a missing index.
Select tables optimized away: MAX(id) was read from the edge of the index while planning.
Using join buffer (hash join): no index serves the join condition (Section 3.12.6).
3.12.5 EXPLAIN ANALYZE and FORMAT=JSON
EXPLAIN ANALYZE executes the statement, discards the result, and prints measurements beside each estimate. The xa function moves each node's figures onto a second line:xa() { sudo mysql shop -Nre "EXPLAIN ANALYZE $1" |
sed -E 's/^( *)(.*[^ ]) +(\(.*)$/\1\2\n\1 \3/'; }
xa "SELECT COUNT(*) FROM orders WHERE customer_id BETWEEN 1000 AND 30000 AND status = 'paid'"
sudo mysql shop -Nre "EXPLAIN ANALYZE FORMAT=JSON SELECT COUNT(*) FROM orders
WHERE customer_id BETWEEN 1000 AND 30000 AND status = 'paid'" |
jq -c '.query_plan.inputs[0] | {estimated_rows, actual_rows, actual_last_row_ms}'
-> Aggregate: count(0)
(cost=64407 rows=1) (actual time=133..133 rows=1 loops=1)
-> Filter: ((orders.`status` = 'paid') and (orders.customer_id between 1000 and 30000))
(cost=50203 rows=61649) (actual time=1.56..132 rows=21780 loops=1)
-> Table scan on orders
(cost=50203 rows=499223) (actual time=1.55..90.6 rows=500000 loops=1)
{"estimated_rows":61648.500645160675,"actual_rows":21780.0,"actual_last_row_ms":127.01337500000
001}
3.12.6 Nested-Loop and Hash Joins
A nested-loop join probes the inner table once per outer row, ideally through an index. A hash join loads the smaller input into a hash table and streams the larger one past it, reading each once. It arrived in 8.0.18, replaced block nested loop in 8.0.20, serves outer, semi- and antijoins too, spills to disk beyond join_buffer_size (256 KB), and obeys BNL/NO_BNL. The classic optimizer uses it only when no index serves the join, so this report over 1.25 million order lines needs hints to get one:xa() { sudo mysql shop -Nre "EXPLAIN ANALYZE $1" |
sed -E 's/^( *)(.*[^ ]) +(\(.*)$/\1\2\n\1 \3/'; }
q="p.category_id, SUM(oi.quantity * oi.unit_price) AS revenue
FROM order_items AS oi JOIN products AS p ON p.id = oi.product_id GROUP BY p.category_id"
xa "SELECT /*+ NO_JOIN_INDEX(p PRIMARY) NO_JOIN_INDEX(oi product_id) */ $q"
-> Table scan on <temporary>
(actual time=1251..1251 rows=4 loops=1)
-> Aggregate using temporary table
(actual time=1251..1251 rows=4 loops=1)
-> Inner hash join (oi.product_id = p.id)
(cost=1.25e+9 rows=1.36e+6) (actual time=10.9..767 rows=1.25e+6 loops=1)
-> Table scan on oi
(cost=0.225 rows=1.25e+6) (actual time=4.01..394 rows=1.25e+6 loops=1)
-> Hash
-> Covering index scan on p using category_id
(cost=1097 rows=10015) (actual time=1.09..5.7 rows=10000 loops=1)
3.12.7 Join Order, Driving Tables, and STRAIGHT_JOIN
FROM order does not decide execution order: the optimizer reorders inner joins (an outer join still reads its preserved side first) to drive from the input that shrinks the rows earliest, which is only as good as its estimates:SELECT COUNT(*) INTO @n FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.country = 'US' AND o.status = 'cancelled';
SELECT STRAIGHT_JOIN COUNT(*) INTO @n FROM orders AS o JOIN customers AS c
ON o.customer_id = c.id WHERE c.country = 'US' AND o.status = 'cancelled';
ANALYZE TABLE customers UPDATE HISTOGRAM ON country;
ANALYZE TABLE orders UPDATE HISTOGRAM ON status;
EXPLAIN SELECT COUNT(*) FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.country = 'US' AND o.status = 'cancelled'\G
Query OK, 1 row affected (0.753 sec)
Query OK, 1 row affected (0.149 sec)
+-------------------+-----------+----------+---------------------------------------------------
-+
| Table | Op | Msg_type | Msg_text
|
+-------------------+-----------+----------+---------------------------------------------------
-+
| shop.customers | histogram | status | Histogram statistics created for column
'country'. |
+-------------------+-----------+----------+---------------------------------------------------
-+
1 row in set (7.037 sec)
+----------------+-----------+----------+---------------------------------------------------+
| Table | Op | Msg_type | Msg_text |
+----------------+-----------+----------+---------------------------------------------------+
| shop.orders | histogram | status | Histogram statistics created for column 'status'. |
+----------------+-----------+----------+---------------------------------------------------+
1 row in set (24.485 sec)
*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0) (cost=68613 rows=1)
-> Nested loop inner join (cost=66056 rows=11102)
-> Filter: (o.`status` = 'cancelled') (cost=50315 rows=24704)
-> Table scan on o (cost=50315 rows=499223)
-> Filter: (c.country = 'US') (cost=0.537 rows=0.449)
-> Single-row index lookup on c using PRIMARY (id = o.customer_id) (cost=0.537
rows=1)
1 row in set (0.001 sec)
3.12.8 Optimizer Hints and Index Hints
An optimizer hint, a /*+ ... */ comment after the first keyword, overrides one decision for one statement, at query-block (JOIN_ORDER, SEMIJOIN), table (MERGE, BNL) or index level (INDEX, NO_INDEX); SET_VAR sets a variable and MAX_EXECUTION_TIME caps a SELECT.SELECT /*+ MAX_EXECUTION_TIME(200) */ COUNT(*) FROM order_items WHERE quantity * unit_price > 90;
SELECT /*+ INDEX(orders customer_id) */ COUNT(*) INTO @n FROM orders
WHERE customer_id BETWEEN 1000 AND 30000 AND status = 'paid';
SELECT /*+ NO_INDX(orders) */ COUNT(*) INTO @n FROM orders WHERE id < 10;
EXPLAIN SELECT /*+ NO_MERGE(dt) NO_DERIVED_CONDITION_PUSHDOWN(dt) */ * FROM
(SELECT id, customer_id FROM orders WHERE status = 'pending') AS dt WHERE dt.customer_id = 42\G
ERROR 3024 (HY000): Query execution was interrupted, maximum statement execution time exceeded
Query OK, 1 row affected (0.381 sec)
Query OK, 1 row affected, 1 warning (0.000 sec)
Warning (Code 1064): Optimizer hint syntax error near 'NO_INDX(orders) */ COUNT(*) INTO @n
FROM orders WHERE id < 10' at line 1
*************************** 1. row ***************************
EXPLAIN: -> Index lookup on dt using <auto_key0> (customer_id = 42) (cost=61699..61702
rows=10)
-> Materialize (cost=61698..61698 rows=49891)
-> Filter: (orders.`status` = 'pending') (cost=50203 rows=49891)
-> Table scan on orders (cost=50203 rows=499223)
1 row in set (0.000 sec)
3.12.9 Sargable Predicates and Query Rewriting
A predicate is sargable when the bare column meets a value computed once, so an index can seek. A function, arithmetic or type conversion on the column forces per-row evaluation: move the work to the constant side (price > 100 / 1.1, a date range, not YEAR(ordered_at) = 2026) or index the expression (Section 3.4.8). The slow query log proves a rewrite; it needs SET GLOBAL, so this ran on a private MySQL 9.7.2 container on the same machine, restarted cold:q="SELECT c.country, SUM(oi.quantity * oi.unit_price) AS revenue FROM orders AS o
JOIN customers AS c ON c.id = o.customer_id JOIN order_items AS oi ON oi.order_id = o.id
WHERE o.status <> 'cancelled' AND"
g="GROUP BY c.country ORDER BY revenue DESC"
sudo mysql shop -e "SET GLOBAL slow_query_log_file = '/var/lib/mysql/slow.log',
slow_query_log = ON, long_query_time = 0.05"
sudo mysql shop -e "$q DATE_FORMAT(o.ordered_at, '%Y-%m') = '2026-08' $g" > /dev/null
sudo mysql shop -e "ALTER TABLE orders ADD INDEX idx_ordered_at (ordered_at)"
sudo mysql shop -e "$q DATE_FORMAT(o.ordered_at, '%Y-%m') = '2026-08' $g" > /dev/null
sudo mysql shop -e "$q o.ordered_at >= '2026-08-01' AND o.ordered_at < '2026-09-01' $g" > /dev/null
sudo grep '^# Query_time' /var/lib/mysql/slow.log
# Query_time: 0.454882 Lock_time: 0.000036 Rows_sent: 10 Rows_examined: 551687
# Query_time: 0.235647 Lock_time: 0.000004 Rows_sent: 10 Rows_examined: 551687
# Query_time: 0.119962 Lock_time: 0.000002 Rows_sent: 10 Rows_examined: 67256
3.12.10 Keyset Pagination and Other Anti-Pattern Fixes
OFFSET 450000 reads and discards 450,000 rows, so every page is slower than the last. Keyset pagination remembers the previous page's last key and seeks past it:xa() { sudo mysql shop -Nre "EXPLAIN ANALYZE $1" |
sed -E 's/^( *)(.*[^ ]) +(\(.*)$/\1\2\n\1 \3/'; }
xa "SELECT id, status FROM orders ORDER BY id LIMIT 20 OFFSET 450000"
xa "SELECT id, status FROM orders WHERE id > 450000 ORDER BY id LIMIT 20"
-> Limit/Offset: 20/450000 row(s)
(cost=40925 rows=20) (actual time=251..251 rows=20 loops=1)
-> Index scan on orders using PRIMARY
(cost=40925 rows=450020) (actual time=5.53..234 rows=450020 loops=1)
-> Limit: 20 row(s)
(cost=21326 rows=20) (actual time=5.69..5.71 rows=20 loops=1)
-> Filter: (orders.id > 450000)
(cost=21326 rows=106490) (actual time=5.69..5.7 rows=20 loops=1)
-> Index range scan on orders using PRIMARY over (450000 < id)
(cost=21326 rows=106490) (actual time=5.68..5.69 rows=20 loops=1)