The optimizer
The optimizer converts your declarative SQL into a physical plan. It is the component people blame most and understand least. The honest framing from chapter 12 holds throughout this part: it is not picking the best plan, it is picking the plan with the lowest estimated cost, from statistics that are sampled and often wrong. Learn to tell those two failures apart and most optimizer problems become tractable.
The cost model
Cost is a unitless number. Historically it was "how many random page reads would this take", and the constants still carry that flavour. They live in two tables you can actually edit.
| Constant | Default | Meaning |
|---|---|---|
io_block_read_cost | 1.0 | Reading one block from disk |
memory_block_read_cost | 0.25 | Reading one block already in the buffer pool |
row_evaluate_cost | 0.1 | Examining one row |
key_compare_cost | 0.05 | Comparing two keys |
disk_temptable_create_cost | 20.0 | Creating a temp table on disk |
memory_temptable_row_cost | 0.1 | One row in a memory temp table |
Notice io_block_read_cost is only 4× memory_block_read_cost.
On real hardware a disk read is orders of magnitude slower than a memory read —
so the model systematically underestimates the penalty of a plan that
misses the buffer pool. That is one structural reason MySQL sometimes prefers a
scan you would not have chosen.
UPDATE mysql.engine_cost and
FLUSH OPTIMIZER_COSTS, and it affects every query on the server.
It is almost never the right fix — indexes, statistics and query shape are.
Know it exists so you recognize it when you inherit a server where someone did
it.Statistics and histograms
From chapter 34: index statistics are sampled from
innodb_stats_persistent_sample_pages pages (default 20). They give
the optimizer cardinality — how many distinct values an index has —
from which it infers selectivity by assuming uniform distribution.
That assumption is the problem. Real data is skewed. A status
column with 99% 'active' and 1% spread across four other values
has cardinality 5, so the optimizer estimates every value matches 20% of the
table. It is catastrophically wrong in both directions at once.
Histograms fix exactly this
8.0 added histograms: an actual distribution of values, stored in the data dictionary, for columns you nominate. Two kinds, chosen automatically:
- Singleton — when distinct values ≤ bucket count, it stores the exact frequency of each value. Perfect accuracy.
- Equi-height — otherwise, buckets each holding roughly the same number of rows, so dense regions get finer resolution.
ANALYZE TABLE ... UPDATE
HISTOGRAM. On a table whose distribution shifts, a stale histogram is
worse than none. Put the refresh in a scheduled job, or do not create it.
Also: histograms are only consulted for columns without a usable index. If the column is indexed, index dives are more accurate and win. Histograms are for the non-indexed filter columns.
-- 1. See the misestimate on a skewed column. EXPLAIN SELECT * FROM orders WHERE status = 'cancelled'; SELECT COUNT(*) FROM orders WHERE status = 'cancelled'; -- compare "rows" to the real count. often 100x out. -- 2. Build a histogram. ANALYZE TABLE orders UPDATE HISTOGRAM ON status, amount WITH 64 BUCKETS; -- 3. Re-EXPLAIN. The estimate should now be close. EXPLAIN SELECT * FROM orders WHERE status = 'cancelled'; -- 4. Inspect what it stored. SELECT JSON_PRETTY(histogram) FROM information_schema.column_statistics WHERE table_name = 'orders' AND column_name = 'status'\G -- 5. Refresh it on a schedule, or drop it. -- ANALYZE TABLE orders DROP HISTOGRAM ON status;
Join algorithms
MySQL has three, and notably lacks a fourth that Postgres has.
| Algorithm | How | Good when | Cost |
|---|---|---|---|
| Nested loop | For each outer row, look up matches in the inner table's index | The join column is indexed and the outer side is small | O(outer × log inner) — excellent with an index |
| Block nested loop | Buffer many outer rows in join_buffer_size, then scan the inner table once per buffer | No index exists. Reduces inner scans but still quadratic-ish. | Bad. Its presence means a missing index. |
| Hash join 8.0.18+ | Build a hash table on the smaller side, stream the larger side through it | Equi-join with no usable index, large tables | O(n + m). Replaced BNL as the default for unindexed equi-joins in 8.0.20. |
| Merge join | — | — | MySQL does not have one. Postgres does. It matters when both inputs are already sorted on the join key. |
Join order search
For N tables there are N! possible join orders. Ten tables is 3.6 million
orderings, each needing cost evaluation. Exhaustive search is infeasible, so
MySQL uses a greedy search with a configurable lookahead:
optimizer_search_depth (default 62, meaning "decide
automatically").
The chosen order matters enormously, because the optimizer wants the most selective table first — filtering early means fewer rows flow through every subsequent join.
EXPLAIN, multiply the rows column down the plan. If
an early table shows a large number and a later one is highly selective, the
order is backwards — you are materializing millions of rows then throwing them
away. The cause is nearly always a wrong estimate (chapter 78), not a broken
search. Fix the statistics before reaching for STRAIGHT_JOIN.Semijoin strategies
A semijoin answers "does a match exist?" without duplicating rows —
WHERE x IN (SELECT ...) and WHERE EXISTS (...).
MySQL has five execution strategies and picks by cost.
| Strategy | Mechanism | Best when |
|---|---|---|
| FirstMatch | Stop scanning the inner table at the first match per outer row | Few matches expected; the most common choice |
| LooseScan | Scan the inner index skipping duplicate keys | The inner side is indexed on the join column with many duplicates |
| MaterializeLookup | Materialize the subquery once into a temp table with a unique index, then probe it | The subquery is expensive and independent of the outer row |
| MaterializeScan | Materialize, then scan it as the outer side | The materialized result is small |
| DuplicateWeedout | Run it as a normal join, then remove duplicates with a temp table of row ids | Fallback when others do not apply |
IN (SELECT ...) was executed literally:
the subquery ran once per outer row. This is the origin of the
folk rule "rewrite IN as a JOIN" — advice that was correct for a decade and is
now largely obsolete. On 8.0, measure before rewriting; the optimizer usually
transforms it into the same plan.Derived tables: merge or materialize
A subquery in FROM is a derived table. The optimizer either
merges it into the outer query — so conditions push down and
indexes work — or materializes it into a temporary table.
-- merged: becomes a plain indexed lookup
SELECT * FROM (SELECT * FROM users WHERE active=1) d WHERE d.id = 42;
-- cannot merge: aggregation forces materialization
SELECT * FROM (SELECT user_id, COUNT(*) c FROM orders
GROUP BY user_id) d WHERE d.user_id = 42;
-- the ENTIRE aggregate is computed, then one row selected.Merging is blocked by aggregation, DISTINCT, LIMIT,
UNION and window functions. When it is blocked, the filter cannot
push down and you may compute a million groups to read one.
derived_merge in optimizer_switch controls this, and
8.0 adds automatic indexes on materialized derived tables
(<auto_key0> in EXPLAIN) which softens the cost
considerably.
Index merge, and when it is a trap
When no single index serves a query, MySQL can use several and combine the results. Three variants:
- Union — for
ORconditions. Scan each index, union the primary keys. - Intersection — for
AND. Scan each, keep keys appearing in all. - Sort-union — union where results need sorting first.
Using intersect(idx_a, idx_b) looks clever but costs two index
scans plus a merge. A single index on (a, b) does it in one
descent and is almost always faster.
Treat index merge in
EXPLAIN as a diagnostic: the
optimizer is telling you which composite index you should have created. The
exception is genuinely ad-hoc OR queries across many columns,
where you cannot index every combination.ORDER BY, GROUP BY, and filesort
"Filesort" is a misleading name — it often happens entirely in memory. It means "sorting without using an index's ordering", nothing more.
Two algorithms
- Single-pass — read all selected columns into the sort buffer, sort, output. Fewer IO operations, but wide rows fill the buffer fast. Preferred when the row fits.
- Two-pass — sort only the sort key plus the row id, then fetch rows in sorted order. Handles wide rows but re-reads them randomly.
If the data exceeds sort_buffer_size, it is sorted in chunks
written to disk and merged — each merge pass increments
Sort_merge_passes. That counter rising is the signal that sorts
are spilling.
The priority queue optimization
For ORDER BY ... LIMIT n with small n, MySQL keeps a heap of the
best n rows instead of sorting everything. Finding the top 10 of a million rows
costs one pass and a tiny heap, not a full sort. This is why adding
LIMIT to an ordered query can be dramatically faster than you would
expect — and why removing it can be dramatically slower.
ORDER BY,
there is no sort at all — MySQL walks the leaves in order. That is why
INDEX (status, created_at) serves
WHERE status=? ORDER BY created_at with no filesort, and why
column order in composite indexes (chapter 35) is so consequential.Reading EXPLAIN ANALYZE properly
EXPLAIN shows the plan. EXPLAIN ANALYZE (8.0.18+)
runs the query and annotates the iterator tree with real timings. It is
strictly more useful, and this is how to read it.
The five things to look at, in order
actual time=A..B— A is time to the first row, B to the last. A blocking operator shows A ≈ B, because it cannot emit anything until it has consumed everything. Above,Sortshows812..812: it is the blocker.loops=N— how many times this node ran. Multiply it by the node's own time to get its true contribution. The index lookup above takes 0.06ms but runs 10,000 times: 600ms, the bulk of the query.- Estimated
rowsversusactual rows— the single most valuable comparison.rows=9891againstrows=10000is healthy. An order-of-magnitude gap means your statistics are wrong, and every cost decision above that node was made on bad data. - Where the time appears — subtract a child's time from its parent's to find where it was actually spent.
- Scan nodes on large tables —
Table scan on uproducing 10,000 rows to feed a join is the thing to fix first.
-- Plan only, no execution. EXPLAIN FORMAT=TREE SELECT ...\G -- Plan + real timings. NOTE: it EXECUTES the query. -- Never run it on an UPDATE/DELETE you do not want applied. EXPLAIN ANALYZE SELECT ...\G -- Full cost numbers, including filtered % and used_key_parts. EXPLAIN FORMAT=JSON SELECT ...\G -- Explain a query that is running RIGHT NOW in another session: EXPLAIN FOR CONNECTION 1234\G -- invaluable during an incident. get the id from -- SHOW PROCESSLIST, then see what plan it actually chose.
Optimizer trace
EXPLAIN tells you what was chosen. The trace tells you
why — every alternative considered, its cost, and the reason it
lost. When a plan makes no sense, this is the tool.
SET optimizer_trace = "enabled=on"; SET optimizer_trace_max_mem_size = 1048576; -- traces get big SELECT * FROM orders o JOIN users u ON u.id=o.user_id WHERE o.status='paid' ORDER BY o.created_at LIMIT 10; SELECT JSON_PRETTY(trace) FROM information_schema.optimizer_trace\G SET optimizer_trace = "enabled=off"; -- The sections that matter, in reading order: -- -- rows_estimation.range_analysis -- → every index considered, with its cost. -- "chosen": false always carries a "cause". -- -- considered_execution_plans -- → each join order tried, and its cumulative cost. -- -- attaching_conditions_to_tables -- → which predicates were pushed to which table. -- -- reconsidering_access_paths_for_index_ordering -- → why it did or did not use an index to avoid the sort.
"cause". Every rejected option records why:
"cost", "not_applicable",
"no_directly_usable_keypart". That single field usually answers
"why is it ignoring my index?" in one line — and the commonest answers are a
type mismatch on the comparison, or a leading column of the index not being in
the predicate.Hints
Two generations of hint syntax, and they behave differently.
| Old style | New style (8.0) | Effect |
|---|---|---|
FORCE INDEX (i) | /*+ INDEX(t i) */ | Restrict to these indexes |
IGNORE INDEX (i) | /*+ NO_INDEX(t i) */ | Exclude these |
STRAIGHT_JOIN | /*+ JOIN_ORDER(a,b,c) */ | Fix the join order |
| — | /*+ NO_BNL(t) */, /*+ BKA(t) */ | Control the join algorithm |
| — | /*+ MAX_EXECUTION_TIME(1000) */ | Kill the query after 1s |
| — | /*+ SET_VAR(sort_buffer_size=16M) */ | Change a variable for one statement |
Prefer the new comment-style hints: they are scoped to one query block, they are ignored rather than fatal on older servers, and they express intent more precisely.
MAX_EXECUTION_TIME is the exception. It is a safety valve, not a
plan decision, and it belongs in production code.CTEs and window functions
Both arrived in 8.0 and both have execution characteristics worth knowing. The SQL module covers their semantics; here we cover how MySQL runs them.
CTEs
A non-recursive CTE is treated like a derived table: merged when possible, materialized otherwise. Crucially, a CTE referenced multiple times is materialized once and reused — unlike a repeated subquery, which is evaluated each time. That makes a CTE a genuine optimization when you need the same intermediate result twice.
Recursive CTEs execute as a fixpoint loop: evaluate the anchor, then repeatedly
evaluate the recursive term against the previous iteration's rows until it
produces none. cte_max_recursion_depth (default 1000) stops
runaway recursion.
Window functions
Executed after WHERE, GROUP BY and
HAVING, but before ORDER BY and
LIMIT — which is why you cannot filter on a window function's
result directly and must wrap it in a subquery or CTE.
Execution needs the rows sorted by PARTITION BY then
ORDER BY. If an index provides that order, the sort is free;
otherwise it is a filesort over the whole result. So a window function over a
large partition set is really a sorting problem, and the index that matches the
partition and order clauses is what makes it fast.
ORDER BY inside OVER() and no explicit frame,
the default is RANGE BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW.
RANGE, not ROWS — so peer rows with equal ordering
values are all included at once. SUM() over ties therefore jumps
rather than incrementing. Specify ROWS explicitly when you want
row-by-row behaviour. Chapter 32 of the SQL module covers this in full.That is the single-server picture complete. Part 9 adds the second machine: binary logs, replication, GTIDs, and the failure modes that come with them.