Part 8 · 12 chapters · ~20 min

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.

77

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.

ConstantDefaultMeaning
io_block_read_cost1.0Reading one block from disk
memory_block_read_cost0.25Reading one block already in the buffer pool
row_evaluate_cost0.1Examining one row
key_compare_cost0.05Comparing two keys
disk_temptable_create_cost20.0Creating a temp table on disk
memory_temptable_row_cost0.1One 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.

editing these is a last resort
You can 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.
78

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.
the crucial limitation
Histograms are not updated automatically. They are a point-in-time snapshot, refreshed only when you re-run 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.
run it
-- 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;
79

Join algorithms

MySQL has three, and notably lacks a fourth that Postgres has.

AlgorithmHowGood whenCost
Nested loopFor each outer row, look up matches in the inner table's indexThe join column is indexed and the outer side is smallO(outer × log inner) — excellent with an index
Block nested loopBuffer many outer rows in join_buffer_size, then scan the inner table once per bufferNo 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 itEqui-join with no usable index, large tablesO(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.
the 8.0.18 change is bigger than it sounds
Before hash join, an unindexed join between two large tables used block nested loop and could take hours. The standard advice was "always index your join columns", which is still good advice — but analytical queries that legitimately join on unindexed columns went from unusable to fast. If you learned MySQL before 2020, this is worth re-testing on queries you wrote off.
join algorithms
nested loop vs hash join
swipe the figure sideways, or tap expand for full screen
1/7
two ways
The same join, two ways. The algorithm chosen depends almost entirely on whether a usable index exists on the join column.
80

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.

the symptom of a bad join order
In 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.
81

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.

StrategyMechanismBest when
FirstMatchStop scanning the inner table at the first match per outer rowFew matches expected; the most common choice
LooseScanScan the inner index skipping duplicate keysThe inner side is indexed on the join column with many duplicates
MaterializeLookupMaterialize the subquery once into a temp table with a unique index, then probe itThe subquery is expensive and independent of the outer row
MaterializeScanMaterialize, then scan it as the outer sideThe materialized result is small
DuplicateWeedoutRun it as a normal join, then remove duplicates with a temp table of row idsFallback when others do not apply
the historical bug worth knowing
Before 5.6, a correlated 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.
82

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.

code
-- 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.

83

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 OR conditions. 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.
index merge usually means a missing composite index
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.
84

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.

the real fix is an index that provides the order
A B+tree is already sorted. If the index matches your 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.
85

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.

-- read INSIDE-OUT. the most indented node runs first. -> Limit: 10 row(s) (actual time=812..812 rows=10 loops=1) -> Sort: o.created_at DESC (actual time=812..812 rows=10 loops=1) -> Stream results (cost=48211 rows=98443) (actual time=0.4..745 rows=98201 loops=1) -> Nested loop inner join (cost=48211 rows=98443) (actual time=0.4..701 rows=98201 loops=1) -> Table scan on u (cost=1024 rows=9891) (actual time=0.1..12 rows=10000 loops=1) -> Index lookup on o using idx_user (user_id=u.id) (actual time=0.05..0.06 rows=9.8 loops=10000)

The five things to look at, in order

  1. 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, Sort shows 812..812: it is the blocker.
  2. 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.
  3. Estimated rows versus actual rows — the single most valuable comparison. rows=9891 against rows=10000 is healthy. An order-of-magnitude gap means your statistics are wrong, and every cost decision above that node was made on bad data.
  4. Where the time appears — subtract a child's time from its parent's to find where it was actually spent.
  5. Scan nodes on large tables — Table scan on u producing 10,000 rows to feed a join is the thing to fix first.
run it
-- 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.
86

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.

run it
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.
what to search for first
Search the JSON for "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.
87

Hints

Two generations of hint syntax, and they behave differently.

Old styleNew 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.

a hint is a bug report you decided not to file
Every hint freezes a decision against data that will change. The index you forced becomes wrong when the table grows, and nobody revisits it. Use hints as a temporary measure while fixing the real cause — a missing index, stale statistics, a mistyped comparison — and leave a comment saying why it is there and what would let it be removed.

MAX_EXECUTION_TIME is the exception. It is a safety valve, not a plan decision, and it belongs in production code.
88

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.

the frame default that surprises people
With an 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.