EXPLAIN shows the query execution plan chosen by the MySQL optimizer. It is the first tool to reach for when a query is slow — it reveals which indexes are used, how many rows are estimated, and whether expensive operations like filesorts or temporary tables are required. Understanding EXPLAIN output is the core skill for MySQL query optimization.
Running EXPLAIN
-- Basic plan
EXPLAIN SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 10;
-- JSON format — more detail, including cost estimates
EXPLAIN FORMAT=JSON SELECT ...;
-- Tree format (MySQL 8.0.16+) — readable nested plan
EXPLAIN FORMAT=TREE SELECT ...;
-- EXPLAIN ANALYZE (MySQL 8.0.18+) — actually runs the query and shows real vs. estimated rows
EXPLAIN ANALYZE SELECT ...;
The EXPLAIN Output Columns
id
The query block number. Each SELECT has its own id. Higher id = inner/subquery (executed first). When rows share the same id, they are part of a JOIN and are processed together.
select_type
How this SELECT relates to the overall query:
SIMPLE— no subqueries or UNIONsPRIMARY— outermost SELECT in a UNION or subquerySUBQUERY— first SELECT in a subquery (in the WHERE clause)DERIVED— a subquery in the FROM clause (materialized into a temp table)UNION— second or subsequent SELECT in a UNIONDEPENDENT SUBQUERY— subquery that references the outer query (runs once per outer row — often very slow)
table
The table being read. <derived2> means the result of query block id=2 (a derived table). <union1,2> means the UNION of blocks 1 and 2.
partitions
Which partitions are accessed. NULL for non-partitioned tables.
type — the most important column
The access method. Best to worst:
| type | Meaning |
|---|---|
system |
Table has exactly one row (constant) |
const |
Primary key or unique index lookup with a constant value — at most one row |
eq_ref |
One row per row from a previous table, using a primary or unique index |
ref |
Multiple rows, using a non-unique index or leftmost prefix of a composite index |
fulltext |
FULLTEXT index lookup |
ref_or_null |
Like ref but also fetches NULL rows |
index_merge |
Multiple indexes combined (Union, Intersection, or Sort-Union) |
range |
Index range scan — only rows within a range are read |
index |
Full index scan — reads the entire index (better than ALL for small indexes) |
ALL |
Full table scan — reads every row |
ALL and index on large tables are almost always problems. range, ref, and eq_ref are good. const is ideal.
possible_keys
Indexes MySQL considered but might not use. NULL means no index is relevant.
key
The index actually chosen. NULL means no index is used (combined with type: ALL = full table scan).
key_len
Bytes of the chosen index used. For composite indexes, a shorter key_len means fewer columns of the index were used. This tells you which prefix of a composite index the optimizer matched.
-- Example: index on (status CHAR(1), customer_id INT, created_at DATETIME)
-- key_len = 1 → only status used
-- key_len = 5 → status + customer_id used (1 + 4)
-- key_len = 13 → all three columns used (1 + 4 + 8)
ref
What is being compared to the key: a constant (const), another column (table.column), or a function.
rows
Estimated number of rows examined. For JOINs, multiply the rows across all tables in the plan to get the total estimated row combinations examined. A product of 10M is a bad plan.
filtered
Percentage of rows the storage engine returns that pass the WHERE filter. rows × filtered / 100 = rows passed to the next step. Low filtered (e.g., 1%) combined with high rows means the server is reading many rows to find a few.
Extra — the second most important column
Additional information about the query execution:
| Extra value | Meaning |
|---|---|
Using index |
All needed columns come from the index alone — no table row lookup. Ideal. |
Using where |
Post-filter applied after rows returned from storage engine |
Using index condition |
Index Condition Pushdown (ICP) — filters evaluated inside storage engine |
Using filesort |
Sort cannot use an index — results buffered and sorted in memory or on disk |
Using temporary |
MySQL creates a temporary table (for GROUP BY, UNION, DISTINCT) |
Using join buffer (hash join) |
Join uses a hash join algorithm (MySQL 8.0.18+) |
Using join buffer (Block Nested Loop) |
Join uses a buffer because no index is available on the join column |
Impossible WHERE |
WHERE condition can never be true — query returns no rows |
Select tables optimized away |
MIN/MAX computed from index without reading data |
No tables used |
FROM clause is absent (e.g., SELECT 1) |
Red flags:
Using filesorton a large result — add a covering index that includes the ORDER BY columnsUsing temporary— redesign the query or add indexes to allow in-memory sortUsing join buffer— add an index on the join column of the inner table
Reading a Multi-Table EXPLAIN
EXPLAIN
SELECT o.id, c.name, oi.product_id, oi.qty
FROM orders o
JOIN customers c ON c.id = o.customer_id
JOIN order_items oi ON oi.order_id = o.id
WHERE o.status = 'pending'
ORDER BY o.created_at;
Example output:
+----+-------------+-------+--------+-------------------+--------------+---------+------------------+------+-------+
| id | select_type | table | type | possible_keys | key | key_len | ref | rows | Extra |
+----+-------------+-------+--------+-------------------+--------------+---------+------------------+------+-------+
| 1 | SIMPLE | o | ref | idx_status | idx_status | 1 | const | 250 | Using filesort |
| 1 | SIMPLE | c | eq_ref | PRIMARY | PRIMARY | 4 | mydb.o.customer_id| 1 | |
| 1 | SIMPLE | oi | ref | idx_order_id | idx_order_id | 4 | mydb.o.id | 3 | |
+----+-------------+-------+--------+-------------------+--------------+---------+------------------+------+-------+
Reading this:
ois the driving table — 250 rows estimated inpendingstatus (ref on idx_status)- For each of those 250 rows,
cis looked up by primary key (eq_ref — fast) - For each of those 250 rows,
oifetches ~3 rows by index on order_id - Total estimated row combinations: 250 × 1 × 3 = 750
Using filesortono— the ORDER BY created_at cannot use the existing idx_status index
Fix the filesort by creating a composite index:
ALTER TABLE orders ADD INDEX idx_status_created (status, created_at);
EXPLAIN ANALYZE (MySQL 8.0.18+)
EXPLAIN ANALYZE runs the query and reports actual rows, actual loops, and actual time alongside the estimates:
EXPLAIN ANALYZE
SELECT * FROM orders WHERE customer_id = 42 ORDER BY created_at DESC LIMIT 10;
Output (FORMAT=TREE style):
-> Limit: 10 row(s) (actual time=0.523..0.524 rows=10 loops=1)
-> Sort: orders.created_at DESC (actual time=0.521..0.522 rows=10 loops=1)
-> Filter: (orders.customer_id = 42) (actual time=0.089..0.501 rows=127 loops=1)
-> Table scan on orders (actual time=0.084..0.461 rows=50000 loops=1)
The actual rows=127 vs rows=50000 scanned tells you: a full table scan read 50,000 rows to find 127 for customer 42. Fix: add INDEX idx_customer_id (customer_id, created_at DESC).
Key numbers from EXPLAIN ANALYZE:
actual rowsvs. estimatedrows— large divergence means stale statisticsactual time=start..end— the start time is the time to get the first row, end is for all rowsloops— how many times this node was executed (high loops on inner tables = nested-loop join concern)
Common Patterns and Fixes
-- Pattern: Extra = "Using filesort" + type = ALL
-- Cause: No usable index for ORDER BY, full scan
-- Fix: Add an index on the ORDER BY column(s) that also covers the WHERE clause
-- Pattern: type = ALL + rows = (table row count)
-- Cause: Full table scan — no WHERE or no usable index
-- Fix: Add an index on the WHERE predicate columns
-- Pattern: Extra = "Using temporary; Using filesort" on GROUP BY
-- Cause: GROUP BY cannot use an index
-- Fix: Add an index on the GROUP BY column(s) (leftmost prefix of a composite index)
-- Pattern: type = index + key = PRIMARY
-- Cause: Full index scan (reads entire clustered index in order) — slightly better than ALL
-- but still reads every row
-- Fix: Add a WHERE condition, or a secondary index that supports a range scan
-- Pattern: rows product across JOIN tables >> actual result rows
-- Cause: Bad join order or missing index on join column
-- Fix: Add index on the join column of the inner table; optimizer may reorder joins
Additional Information
EXPLAINdoes not run the query (exceptEXPLAIN ANALYZE). Therowscolumn is an estimate that can be wrong by orders of magnitude on tables with stale statistics. RunANALYZE TABLE tablenameto refresh statistics before relying on EXPLAIN output.EXPLAIN FORMAT=JSONincludes thecost_infoblock with the optimizer's cost estimates for each step. This is useful for understanding why the optimizer chose one plan over another.- MySQL 8.0's optimizer_trace (
SET SESSION optimizer_trace = "enabled=on") produces a complete JSON trace of the decision process, including all evaluated access paths and their costs. This is the authoritative source for understanding optimizer choices. EXPLAINfor DML (EXPLAIN INSERT ... SELECT,EXPLAIN UPDATE,EXPLAIN DELETE) shows the plan for the SELECT part of the statement.
Frequently Asked Questions
Q: EXPLAIN shows rows=1 but the query takes 5 seconds. What's happening?
A: The rows estimate can be wrong. Use EXPLAIN ANALYZE to see actual rows. The slowness may also be in lock waits — check Lock_time in the slow query log.
Q: My query has type=ref but is still slow. Is that a problem with ref?
A: ref is generally good but depends on selectivity. A ref scan returning 500,000 rows from a low-cardinality index (e.g., status IN ('active', 'pending') on a mostly-active table) is effectively a full scan. Check rows and filtered.
Q: EXPLAIN shows key=NULL but possible_keys lists an index. Why?
A: The optimizer considered the index but estimated a full table scan to be cheaper. Common causes: stale statistics (run ANALYZE TABLE), or the predicate returns too many rows for the index to help. Check key_len — if it's less than expected, the index is only partially matched.
Q: What does key_len tell me about my composite index?
A: key_len is the total bytes of the index used. Each column contributes based on its type: INT = 4 bytes, BIGINT = 8 bytes, DATETIME = 8 bytes, VARCHAR(N) utf8mb4 = N×4+2 bytes (nullable adds 1). A key_len shorter than the full index means not all columns are being used — check whether the WHERE clause covers all prefix columns.
Q: Is Using index condition good or bad?
A: Good — it means Index Condition Pushdown (ICP) is active, evaluating filter conditions inside the storage engine on index records before fetching the full row. It reduces the number of full-row reads. ICP is used when part of the WHERE can be evaluated on index data before returning to the table.