EXPLAIN asks the optimizer what it intends to do with a query, before it does it. It
is the difference between guessing why something is slow and knowing. Every plan below was taken
from pizza_lab — the pizza schema at 400,000 orders and
1,000,000 line items.
Reading a plan
EXPLAIN SELECT COUNT(*) FROM customer_order WHERE status = 'PAID';+----+-------------+----------------+------------+------+---------------------------+---------------------------+---------+-------+-------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------------+------------+------+---------------------------+---------------------------+---------+-------+-------+----------+-------------+
| 1 | SIMPLE | customer_order | NULL | ref | idx_customer_order_status | idx_customer_order_status | 122 | const | 14398 | 100.00 | Using index |
+----+-------------+----------------+------------+------+---------------------------+---------------------------+---------+-------+-------+----------+-------------+One row per table the query touches. The columns that repay attention:
type | How the table is reached. The first thing to look at. |
possible_keys | Indexes that could have been used. |
key | The one actually chosen. NULL means none. |
key_len | How many bytes of a composite index were used — this is how you tell whether all its columns are contributing. |
rows | Estimated rows examined at this step. |
filtered | Estimated percentage surviving the WHERE. |
Extra | The important footnotes. |
The type values, best to worst
const / system | At most one row, via a primary or unique key. As good as it gets. |
eq_ref | One row per row of the previous table, via a unique key. Ideal for a join. |
ref | Several rows matching a non-unique index. The common good case. |
range | An index range — >, BETWEEN,
IN. Fine. |
index | A scan of the whole index. Better than
ALL because the index is narrower, and still a scan. |
ALL | A full table scan. Fine on a small table, a problem on a large one. |
ALL is not automatically a bug: reading a small table completely is often the
cheapest plan, and the optimizer knows it. ALL on a large table with a selective
WHERE is the thing to chase.
The Extra column
Using index | Good. Covered — the table itself was never read. |
Using where | Neutral. Rows were filtered after being read. |
Using index condition | Good. Index condition pushdown — filtering happened at the index. |
Using filesort | A sort was needed. Despite the name, usually in memory. |
Using temporary | A temporary table was built — common with
GROUP BY. Worth investigating with Using filesort. |
EXPLAIN SELECT id, customer_name FROM customer_order WHERE status = 'PAID' ORDER BY customer_name LIMIT 10;+----+-------------+----------------+------------+------+---------------------------+---------------------------+---------+-------+-------+----------+----------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+----------------+------------+------+---------------------------+---------------------------+---------+-------+-------+----------+----------------+
| 1 | SIMPLE | customer_order | NULL | ref | idx_customer_order_status | idx_customer_order_status | 122 | const | 14398 | 100.00 | Using filesort |
+----+-------------+----------------+------------+------+---------------------------+---------------------------+---------+-------+-------+----------+----------------+The index found the 14,398 paid orders, then all of them were sorted to return ten. An index on
(status, customer_name) would provide the order directly and remove the sort — see
indexes.
⚠️ `rows` is an estimate, and it moves
This deserves its own heading, because plans are usually presented as though the numbers were
measurements. They are not. The optimizer derives rows from sampled index
statistics, and re-running the identical query gives a different figure:
run 1: rows = 395261
run 2: rows = 394155 -- same query, same data, seconds apartThose are two runs of one EXPLAIN against an unchanged table. The estimate is also
frequently wrong in a more interesting way: the plan above estimates 14,398 rows for
status = 'PAID' where the true count is 8,000 — off by 80%.
None of that makes EXPLAIN less useful. It means:
- Read
rowsas an order of magnitude, not a number. Ten versus ten million is the signal; 14,398 versus 8,000 is noise. - A badly wrong estimate is itself a finding — it is how the optimizer picks a bad plan, and
ANALYZE TABLEis the first thing to try. - When you need real numbers, use
EXPLAIN ANALYZE.
EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT COUNT(*) FROM customer_order WHERE status = 'PAID';It runs the query — which is the point, and the caveat. The output pairs each
step's estimate with what actually happened:
(cost=1650 rows=14398) (actual time=0.05..8.2 rows=8000 loops=1). Estimated 14,398,
actually 8,000.
Because it executes, do not point it at an UPDATE or DELETE on
anything you care about, and expect it to take as long as the query does. Available from MySQL
8.0.18.
FORMAT=TREE
EXPLAIN FORMAT=TREE SELECT COUNT(*) FROM customer_order WHERE status = 'PAID'\G*************************** 1. row ***************************
EXPLAIN: -> Aggregate: count(0) (cost=3089 rows=1)
-> Covering index lookup on customer_order using idx_customer_order_status (status='PAID') (cost=1650 rows=14398)End the statement with \G rather than a semicolon. Tree output is one long
multi-line string, and in the client's default table mode it is drawn inside a box that wraps
badly; \G prints it vertically and readably.
The same plan as a nested tree, read inside-out: the index lookup feeds the aggregate. For
anything with several joins this is far easier to follow than the table, because it shows the
order of operations rather than one row per table. FORMAT=JSON gives the
fullest detail, including costs, and is what tooling parses.
A join plan
EXPLAIN SELECT o.id, i.product_name
FROM customer_order o
JOIN order_item i ON i.order_id = o.id
WHERE o.status = 'PAID';+----+-------------+-------+------------+------+-----------------------------------+---------------------------+---------+----------------+-------+----------+-------------+
| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
+----+-------------+-------+------------+------+-----------------------------------+---------------------------+---------+----------------+-------+----------+-------------+
| 1 | SIMPLE | o | NULL | ref | PRIMARY,idx_customer_order_status | idx_customer_order_status | 122 | const | 14398 | 100.00 | Using index |
| 1 | SIMPLE | i | NULL | ref | idx_order_item_order | idx_order_item_order | 8 | pizza_lab.o.id | 2 | 100.00 | NULL |
+----+-------------+-------+------------+------+-----------------------------------+---------------------------+---------+----------------+-------+----------+-------------+Three things to read here. Rows are listed in join order — o is
driven first, then i for each of its rows. The ref column names
the source: pizza_lab.o.id means i is looked up by the id from
o. And the row counts multiply: roughly 14,398 × 2 line-item lookups.
That product, not either number alone, is the query's cost.
The most common join problem is the driving table being wrong, or the second table showing
ALL — which means the join column is unindexed and MySQL is scanning the whole table
once per outer row.
How to use it
- Find the slow query — the slow query log or
SHOW FULL PROCESSLIST. EXPLAINit. Look attypeandrowsfirst.- Find the step examining far more rows than it returns.
- Ask why: no index, an index defeated by a function, a bad estimate, or a genuinely unselective filter.
- Change one thing. Re-run
EXPLAIN. Confirm withEXPLAIN ANALYZE.
And always EXPLAIN a new query against a large table before running it.
That habit is the cheapest incident prevention available.
What to remember
typefirst:ALLon a big table with a selective filter is the target.Using indexis good;Using filesortandUsing temporarytogether are worth a look.rowsis a sampled estimate — it drifts between runs and was 80% out here.EXPLAIN ANALYZEruns the query and gives real counts.FORMAT=TREEfor anything with joins.