Reading MySQL EXPLAIN output
How to read the EXPLAIN plan column by column — type, key, rows, filtered, Extra — what the values mean, what to fix, and how the driving table cascades through JOINs.
On this page
EXPLAIN is the single most useful tool for MySQL performance work. Prepend it to a SELECT, UPDATE, DELETE, INSERT, or REPLACE and MySQL returns the execution plan it intends to use — the strategy, the indexes, and an estimate of how much work each step does.
EXPLAIN SELECT *
FROM users
WHERE status = 'active'
ORDER BY created_at DESC;
This note walks the four columns that matter (type, key, rows, Extra), explains the filtered column properly (it’s the most misread), and shows how the driving table cascades through JOINs.
The columns to actually read
| Column | What it means | What to look for |
|---|---|---|
id | Sequential SELECT identifier. Higher numbers run first. | Useful for subqueries and UNIONs. |
select_type | SIMPLE, PRIMARY, SUBQUERY, DERIVED, … | Tells you the structural shape of the query. |
table | The table the row refers to. | Useful in multi-table plans. |
type | How MySQL searches the table. | See “Decoding type” below. |
possible_keys | Indexes MySQL could use. | NULL here means no usable index — add one. |
key | The index MySQL actually chose. | NULL means full table scan. |
key_len | Length of the chosen key prefix used. | Shorter is generally better; longer means more bytes per probe. |
ref | What’s compared against the key (column or constant). | Tells you which outer column feeds the inner lookup. |
rows | Estimated rows MySQL must examine. | Lower is better. |
filtered | Estimated % of those rows that survive the row condition. | See “filtered” section. |
Extra | Extra notes about the plan. | See “Decoding Extra” below. |
Decoding the type column (best to worst)
-
system / const — single row by primary or unique key
Lightning-fast. The optimizer knows the result is exactly one row before the query runs.
-
eq_ref — one row from this table per outer row
Excellent. Used on the inner side of a join when the join column is a primary or unique key.
-
ref — non-unique index lookup, possibly many rows
Good. MySQL is finding matching rows via a regular index. Fine as long as the index is selective.
-
range — index range scan (BETWEEN, >, <, IN)
Good. The engine walks a portion of the index. Watch the
rowsestimate — a wide range over a low-selectivity column degrades fast. -
index — full index scan
Poor. The engine is walking the entire index tree. Cheaper than a table scan because the index is narrower, but still a lot of reads.
-
ALL — full table scan
Bad. The engine reads every row from disk. On a million-row table this is a job, not a query. Almost always means a missing or unusable index on the
WHEREcolumns.
Decoding the Extra column
Green flags (good):
Using index— covering index hit. MySQL answered the query from the index alone, never touching the table data.Using index condition— Index Condition Pushdown. MySQL is filtering at the index level before fetching the row.
Red flags (need optimization):
Using filesort— MySQL cannot use the index to sort and is doing an extra sort pass. Slow; may spill to disk.Using temporary— MySQL is materializing an intermediate result, usually because of complexGROUP BYorORDER BYover a join. Major bottleneck.Using wherecombined withtype: ALL— pulling every row off disk and filtering in memory.
The filtered column (most misread part of EXPLAIN)
filtered is the estimated percentage of the rows count that survive the row-level predicates. Higher is better — high filtered means the index narrowed down the work effectively and MySQL didn’t pull rows it would then discard.
Mental model: imagine hiring a recruiter to find 10 engineers.
The recruiter brings 1,000 resumes (rows = 1000). Only 100 are actually engineers (filtered = 10). You did the heavy lifting of reading 900 useless resumes. MySQL did the equivalent — fetched 900 rows it had to discard.
The recruiter brings 100 resumes (rows = 100). All 100 are engineers (filtered = 100). Zero wasted reads. MySQL pulled exactly what it needed.
The catch: filtered = 100% is great only when rows is also small. A full table scan over 5M rows that all match is technically 100% efficient and still terrible because you moved 5M rows. The real target is low rows, high filtered.
Why filtered matters more in JOINs
MySQL uses a nested loop join: for each row kept from the outer table, probe the inner table once. The number of probes is rows × filtered, not rows alone — filtered decides how many outer rows actually drive inner lookups.
Concrete example — find active users in New York who ordered today, joining users to orders:
rows = 10000New York usersfiltered = 10%— only 10% are active- Passed to
orders:10000 × 0.10 = 1000rows - But MySQL pulled 10,000 user rows off disk to discover this. 9,000 wasted reads.
- Index on
(state, status)finds active New Yorkers directly rows = 1000,filtered = 100%- Passed to
orders:1000 × 1.00 = 1000rows - Same
ordersprobe count, but zero wasted I/O on the users side.
The danger compounds across 3+ tables: if step 1 passes 10× too many rows, step 2 does 10× the lookups, step 3 does 100× the lookups, and the query freezes.
Picking the driving table
The driving table is the first table MySQL reads — the outer of the nested loop. It’s the top row of the EXPLAIN output. Every subsequent row is a probe driven by the rows passed from above.
The optimizer’s goal when picking the driving table: the table that will pass the fewest rows to the next step. It uses table statistics and predicate selectivity to guess.
When the optimizer guesses wrong (often because of stale statistics or unusual data distribution), the query slows down dramatically. Diagnosis: read EXPLAIN top to bottom, watch for an early row with a surprisingly high rows × filtered product.
The blunt fix is STRAIGHT_JOIN, which forces MySQL to read tables in the textual order:
SELECT *
FROM users
STRAIGHT_JOIN orders ON orders.user_id = users.id
WHERE users.status = 'active';
Actionable fixes by symptom
| Axis | Symptom in EXPLAIN | Usual fix |
|---|---|---|
| type: ALL | Full table scan | Add an index on the WHERE columns |
| Using filesort | Sort step on non-indexed column | Composite index covering WHERE + ORDER BY columns |
| Using temporary | Materialized intermediate (often GROUP BY) | Index that supports the GROUP BY order, or rewrite to avoid temp |
| key = NULL but possible_keys ≠ NULL | Optimizer rejected the index (low selectivity, stale stats) | Run ANALYZE TABLE, consider a hint or a better composite |
| key_len unexpectedly large on VARCHAR | Indexing more bytes than searches need | Use a prefix index — INDEX (col(10)) |
| SELECT * | Forces row lookup, breaks covering indexes | Project only the columns you need |
A protocol for tuning a slow query
-
Run EXPLAIN, find the worst row
Read top to bottom. The row with the highest
rows × filteredproduct is where most of the work happens. -
Diagnose by type and Extra
type: ALL→ missing index.Using filesort/Using temporary→ sort or aggregation can’t ride an index.key: NULL→ optimizer rejected its options. -
Add or fix an index
For equality + sort, build
(equality_col, sort_col). For range + sort, the sort column must come before the range to be index-orderable. -
Re-run EXPLAIN, then EXPLAIN ANALYZE
EXPLAIN ANALYZE(MySQL 8.0.18+) actually runs the query and reports actual row counts and timing per iterator alongside the estimates. Large divergence between estimated and actual rows means stale statistics — runANALYZE TABLEand re-check. -
Verify with a real workload
A plan that looks good in isolation can still degrade under concurrency. Measure latency at p99 with realistic data volume.
What to remember
The four columns are type, key, rows, Extra. The fifth, filtered, only makes sense in pair with rows — together they tell you how much real work the step does. EXPLAIN shows the optimizer’s plan, but the optimizer is constrained by the rules in 0005 execution order · MySQL ; understanding both layers is what makes the plan readable. For the specific case of “did this row exist?”, read the plan that 0006 EXISTS produces · MySQL and notice it never multiplies rows. And for ORM-generated queries that surface as EXPLAIN mysteries, see 0001 the JPA N+1 note · Java — that’s often where the offending query came from.