How to Read MySQL EXPLAIN: type, key, rows and Extra
MySQL EXPLAIN shows how a query runs: the access type from const to ALL, the chosen index, estimated rows, Extra notes like Using filesort, and EXPLAIN ANALYZE.
- Course: SQL study plan
- Module: Indexes and performance
- Kind: Lesson
- Reading time: 16 min
- Runtime: MySQL 8 syntax on SQLite 3.49
How do you read MySQL EXPLAIN output?
Read MySQL EXPLAIN one row per table access, in join order. The type column says how rows are found, from const and eq_ref (one row by key) through ref and range down to index and ALL (full scans). The key column is the index chosen, rows is the estimated number examined, and Extra flags extra work such as Using filesort or Using temporary.
Lesson
An index you created is a hope; a query plan is the server telling you what it will actually do. Every performance conversation — in a code review, an incident or an interview — ends with "what does EXPLAIN say?". This lesson teaches MySQL 8's plan output column by column, the access-type ladder you should know by heart, the Extra notes that signal trouble, and EXPLAIN ANALYZE, which runs the query and shows the real numbers.
The exercise engine here is SQLite, whose EXPLAIN QUERY PLAN looks nothing like MySQL's, so this lesson is reading: the plans below are MySQL's, and the exercises analyse plan rows stored in a table.
Asking for a plan
Put EXPLAIN in front of a SELECT (or an UPDATE, DELETE, INSERT … SELECT). The query is planned, not run.
EXPLAIN
SELECT o.id, o.amount
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.city = 'Pune'
ORDER BY o.amount DESC;
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | c | ref | PRIMARY,idx_city | idx_city | 243 | const | 120 | 100.00 | Using index; Using temporary; Using filesort |
| 1 | SIMPLE | o | ref | idx_orders_customer | idx_orders_customer | 4 | shop.c.id | 9 | 100.00 | NULL |
One row per table access, listed in the join order the optimizer chose: here it starts from the Pune customers and, for each, looks up their orders.
The columns
- id and select_type — which
SELECTthe row belongs to:SIMPLEfor a query without subqueries or unions,PRIMARYfor the outer one,SUBQUERY,DERIVED(a derived table inFROM),UNION. - table — the table or alias accessed in this step.
- type — how rows are found. The most important column; see the ladder below.
- possible_keys — indexes that could apply.
NULLhere means no index matches the predicates at all. - key — the index actually chosen, or
NULLfor none. - key_len — how many bytes of the index key are used: on a composite index, a short
key_lentells you only the first column was used to seek. - ref — what the key is compared with:
constfor a literal,db.table.columnfor a join column. - rows — the estimated number of rows examined in this step, per lookup. For a nested-loop join, multiply the steps'
rows(andfiltered) to estimate the total work. - filtered — the estimated percentage of those rows that survive the remaining conditions.
- Extra — everything else, and often the most revealing part.
The access-type ladder
From best to worst — memorise the order:
| type | Meaning |
|---|---|
system / const | at most one matching row, found by a primary key or unique index compared with a constant; read once |
eq_ref | in a join, exactly one row per row of the previous table, through a primary key or unique NOT NULL index |
ref | all rows matching one value of a non-unique index (or a prefix of a unique one) |
ref_or_null | like ref, plus the IS NULL rows |
index_merge | several indexes searched and their results combined |
range | a range of one index: <, >, BETWEEN, IN (…), LIKE 'abc%' |
index | the whole index read from start to end — a full scan, but of the smaller structure |
ALL | the whole table read — a full table scan |
ALL on a big table is the usual culprit. index is better only because the index is smaller than the table; it is still reading everything. Seeing ALL on the inner table of a join is the worst case: the whole table read once per outer row. MySQL 8.0.18 and later replace that with a hash join, shown as Using join buffer (hash join) — better, but still a sign an index on the join column is missing.
Extra: what to look for
Good signs:
Using index— a covering index: the step is answered from the index alone, never touching the rows.Using index condition— index condition pushdown: part of theWHEREis tested on index entries before rows are fetched.Using index for skip scan,Backward index scan— the index is used in a less obvious way.
Warning signs:
Using filesort— the rows are sorted forORDER BY/GROUP BYbecause no index delivers that order. Not a file necessarily; it is any sort the server performs. Cheap on fifty rows, expensive on five million.Using temporary— an internal temporary table is built, typically for aGROUP BYorDISTINCTon columns from different tables, or aUNION.Using wherewithtype = ALL— every row is read and most are thrown away.Using join buffer— a join without a usable index on the inner table.
Neutral: Using where on its own just means a condition is checked after the access method.
A slow query, diagnosed
EXPLAIN SELECT * FROM orders WHERE YEAR(placed_on) = 2024 ORDER BY amount;
| table | type | possible_keys | key | rows | filtered | Extra |
|---|---|---|---|---|---|---|
| orders | ALL | NULL | NULL | 498211 | 100.00 | Using where; Using filesort |
Read it left to right: full table scan (ALL), no candidate index (possible_keys NULL — even though placed_on is indexed, because the column is wrapped in a function), half a million rows examined, and then a sort. The fix is a predicate the index can use — placed_on >= '2024-01-01' AND placed_on < '2025-01-01' — after which the plan shows type: range, key: idx_orders_placed_on and far fewer rows. The next lesson is about exactly that rewrite.
EXPLAIN ANALYZE: the real numbers
EXPLAIN shows estimates. EXPLAIN ANALYZE (MySQL 8.0.18+) runs the query and prints the plan as a tree, each node with the estimate and what happened:
-> Sort: o.amount DESC (actual time=4.21..4.30 rows=1083 loops=1)
-> Nested loop inner join (cost=520.4 rows=1080) (actual time=0.09..3.62 rows=1083 loops=1)
-> Covering index lookup on c using idx_city (city='Pune') (cost=12.3 rows=120) (actual time=0.05..0.11 rows=118 loops=1)
-> Index lookup on o using idx_orders_customer (customer_id=c.id) (cost=3.1 rows=9) (actual time=0.02..0.03 rows=9.2 loops=118)
Read it from the most indented line outwards. actual time=first..last is milliseconds to the first and last row, rows is per loop, and loops is how many times the node ran — rows=9.2 loops=118 is about 1,083 rows in total. The biggest gap between estimated and actual rows is where the optimizer was misled, usually by stale statistics (ANALYZE TABLE orders; refreshes them). Since EXPLAIN ANALYZE really executes the statement, never point it at a slow DELETE on production. EXPLAIN FORMAT=TREE gives the same tree without running it, and FORMAT=JSON adds the cost model's figures.
PostgreSQL's equivalents are EXPLAIN and EXPLAIN (ANALYZE, BUFFERS), with node names such as Seq Scan, Index Scan, Index Only Scan and Bitmap Heap Scan in place of MySQL's type column.
Practise
Both exercises work on plan rows stored as data — the columns of MySQL's EXPLAIN in a table. The first lists the full table scans, biggest first. The second ranks each step's access type on the ladder and reports each query's weakest step and whether it sorts.
Common questions
What does type ALL mean in MySQL EXPLAIN?
type ALL is a full table scan: the server reads every row of that table and tests the WHERE condition on each. On a small table it is fine; on a large one, or as the inner table of a join, it is usually the cause of a slow query. Look for a missing index or a predicate that cannot use one.
What is the difference between EXPLAIN and EXPLAIN ANALYZE?
EXPLAIN only plans the query and shows the optimizer's estimates without running it. EXPLAIN ANALYZE, added in MySQL 8.0.18, actually runs the query and prints the plan as a tree with real timings, actual row counts and loop counts beside the estimates, so you can see where the estimate was wrong. Do not run it on a slow data-changing statement in production.
What does Using filesort mean?
Using filesort means MySQL must sort the rows itself for ORDER BY or GROUP BY because no index delivers them in the needed order. Despite the name it happens in memory when the data fits the sort buffer. It is cheap for a few rows and costly for many; an index whose prefix matches the filter and then the sort columns removes it.
What does Using index mean in the Extra column?
Using index means the query is answered from the index alone, a covering index, without reading the table's rows. It is one of the best things to see in a plan. Do not confuse it with Using index condition, which means index condition pushdown is filtering entries inside the index before rows are fetched.
Why does MySQL not use my index?
The optimizer estimated a different plan was cheaper, or could not use the index at all. Common causes are a function or type conversion on the column, a leading wildcard in LIKE, a filter missing the composite index's first column, low selectivity so a scan is cheaper, or stale statistics, which ANALYZE TABLE refreshes.
Exercises
Find the full table scans
plan_rows stores the EXPLAIN output of several slow queries, one row per table access. List every full table scan (type = 'ALL'), with a hint for the fix: 'no candidate index' when possible_keys is NULL, otherwise 'index not chosen'.
plan_rows:
| query_id | step | table_name | type | possible_keys | key_name | rows_examined | extra |
|---|---|---|---|---|---|---|---|
| 1 | 1 | customers | ref | idx_city | idx_city | 120 | Using index |
| 1 | 2 | orders | ALL | NULL | NULL | 498211 | Using where; Using join buffer (hash join) |
| 2 | 1 | orders | range | idx_placed_on | idx_placed_on | 3100 | Using index condition; Using filesort |
| 3 | 1 | products | ALL | idx_category | NULL | 2400 | Using where |
| 3 | 2 | reviews | ref | idx_product | idx_product | 14 | NULL |
| 4 | 1 | users | const | PRIMARY | PRIMARY | 1 | NULL |
| 5 | 1 | events | index | idx_tenant_time | idx_tenant_time | 75000 | Using index; Using filesort |
Output columns: query_id, table_name, rows_examined, fix_hint, ordered by rows_examined descending, then query_id, then table_name.
query_id | table_name | rows_examined | fix_hint
1 | orders | 498211 | no candidate index
3 | products | 2400 | index not chosenEach query's weakest step
Rank every step of plan_rows on MySQL's access-type ladder — system 1, const 2, eq_ref 3, ref 4, ref_or_null 5, index_merge 6, range 7, index 8, ALL 9 — and report, for each query, the type of its worst step (highest rank) and whether any of its steps sorts (extra contains Using filesort). The same plan_rows table as the previous exercise; extra can be NULL.
Output columns: query_id, worst_type, needs_sort ('yes' or 'no'), ordered by query_id.
query_id | worst_type | needs_sort
1 | ALL | no
2 | range | yes
3 | ALL | no
4 | const | no
5 | index | yesIn this module: Indexes and performance
← Composite indexes and the leftmost prefix · Sargable queries — predicates an index can use →