MySQL EXPLAIN: Query Execution Plans
EXPLAIN is the single most important tool for query optimization in MySQL. It shows how the MySQL optimizer plans to execute a query — which indexes it will use, how many rows it expects to examine, and what operations it will perform. Understanding EXPLAIN output lets you identify and fix slow queries before they become production problems.
Basic EXPLAIN Syntax
-- Prepend EXPLAIN to any SELECT, INSERT, UPDATE, DELETE, or REPLACE EXPLAIN SELECT u.name, COUNT(o.id) AS order_count FROM users u LEFT JOIN orders o ON o.user_id = u.id WHERE u.created_at > '2024-01-01' GROUP BY u.id;
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-----------------------------+ | id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra | +----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-----------------------------+ | 1 | SIMPLE | u | NULL | ALL | NULL | NULL | NULL | NULL | 150000 | 33.33 | Using where; Using temporary| | 1 | SIMPLE | o | NULL | ref | user_id | user_id | 4 | u.id | 3 | 100.00 | NULL | +----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-----------------------------+
All Output Columns Explained
Column | Description |
|---|---|
id | Query block number. Rows with the same id belong to the same SELECT. Higher id = subquery executed first. |
select_type | The type of SELECT: SIMPLE, PRIMARY, SUBQUERY, DERIVED, UNION, UNION RESULT, DEPENDENT SUBQUERY, etc. |
table | The table being accessed. Can be a real table name, alias, or a derived table label like <derived2>. |
partitions | Which partitions will be scanned. NULL if the table is not partitioned. |
type | The access type — the most important column. See the full list below. |
possible_keys | All indexes MySQL considered for this table access. NULL means no usable index exists. |
key | The index MySQL actually chose to use. NULL means no index is used — potential full scan. |
key_len | How many bytes of the chosen index MySQL uses. Useful for composite indexes — tells you how many columns are used. |
ref | Which columns or constants are compared to the key. Shows what is being looked up in the index. |
rows | Estimated number of rows MySQL will examine. Lower is better. Multiply across all tables for total work estimate. |
filtered | Estimated percentage of rows that pass the WHERE condition after the index access. rows * filtered/100 = actual expected rows. |
Extra | Additional information about the execution — very important for diagnosing performance issues. See the Extra section. |
Access Types — The type Column
The type column shows how MySQL accesses each table. Listed from best to worst:
Type | When It Occurs | Performance |
|---|---|---|
system | Table has exactly one row (system table or const with 1 row). | Instant |
const | At most one matching row — PRIMARY KEY or UNIQUE index lookup with a constant value. | Excellent |
eq_ref | Exactly one row returned per row from the previous table — UNIQUE index or PK join. | Excellent |
ref | All rows matching an index value — non-unique index or leftmost prefix of a unique index. | Good |
fulltext | A FULLTEXT index is used. | Good for text search |
ref_or_null | Like ref but also searches for NULL values in the indexed column. | Good |
index_merge | MySQL uses multiple indexes and merges the results (via union or intersection). | Moderate |
range | Index range scan — used for BETWEEN, IN(), >, <, >=, <=, LIKE prefix. | Moderate |
index | Full index scan — reads every leaf node of the index without accessing the table. Better than ALL, but still slow on large indexes. | Slow |
ALL | Full table scan — every row is read. Avoid on tables with more than a few thousand rows. | Worst |
type: ALL on a table with many rows, that query needs an index. Adding or fixing an index to achieve at least type: ref or type: range is usually the highest-impact optimization you can make.The Extra Column — Full Dictionary
Extra Value | Meaning | Action Needed |
|---|---|---|
Using index | Covering index — query answered entirely from the index, no table row access needed. Best case. | None — this is ideal |
Using where | WHERE clause filters rows after the storage engine returns them. Normal for non-index conditions. | Check if a better index can reduce rows read first |
Using index condition | Index Condition Pushdown (ICP) — conditions are applied at the storage engine level before returning rows to SQL layer. | Usually fine — ICP is an optimization |
Using filesort | MySQL must sort results in memory or on disk. Appears when ORDER BY columns are not covered by an index. | Add an index matching the ORDER BY columns |
Using temporary | MySQL creates an internal temporary table — common with GROUP BY on non-indexed columns or complex DISTINCT queries. | Investigate — can be expensive for large datasets |
Using join buffer (Block Nested Loop) | No index on the inner table join column. MySQL buffers rows in memory for a nested loop join. | Add an index on the join column |
Impossible WHERE | WHERE condition is always false — no rows can ever match. Query returns empty instantly. | Fix the query logic — often a type mismatch or impossible range |
Select tables optimized away | Query resolved entirely via index (e.g., MIN/MAX with a single-column index, COUNT(*) from information_schema). | Ideal — nothing to do |
Using MRR | Multi-Range Read optimization is active — randomizing disk read order to reduce seeks. | Fine — this is an optimizer optimization |
No tables used | Query has no FROM clause, or uses only constants. | Normal for SELECT 1, SELECT NOW(), etc. |
EXPLAIN FORMAT=JSON
The JSON format provides more detail including cost estimates, which are not available in the tabular format:
EXPLAIN FORMAT=JSON SELECT * FROM orders WHERE customer_id = 42G
{
"query_block": {
"select_id": 1,
"cost_info": {
"query_cost": "1.05"
},
"table": {
"table_name": "orders",
"access_type": "ref",
"possible_keys": ["idx_customer_id"],
"key": "idx_customer_id",
"used_key_parts": ["customer_id"],
"key_length": "4",
"ref": ["const"],
"rows_examined_per_scan": 3,
"rows_produced_per_join": 3,
"filtered": "100.00",
"cost_info": {
"read_cost": "0.75",
"eval_cost": "0.30",
"prefix_cost": "1.05"
}
}
}
}Key JSON-only fields:
- query_cost: total optimizer cost estimate for the entire query
- read_cost: estimated cost to read rows from storage
- eval_cost: estimated cost to evaluate row conditions
- rows_examined_per_scan: how many rows the storage engine scans per outer table loop
EXPLAIN ANALYZE (MySQL 8.0+)
EXPLAIN ANALYZE actually executes the query and reports real row counts and timing alongside the optimizer's estimates. This reveals when optimizer estimates are wildly off from reality — a common cause of poor query plans.
EXPLAIN ANALYZE SELECT u.name, COUNT(o.id) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.idG
-> Table scan on <temporary> (actual time=8.45..8.51 rows=1000 loops=1)
-> Aggregate using temporary table (actual time=8.43..8.43 rows=1000 loops=1)
-> Nested loop inner join (cost=4521.40 rows=3000) (actual time=0.10..6.20 rows=5000 loops=1)
-> Table scan on u (cost=1023.30 rows=10000) (actual time=0.05..1.80 rows=10000 loops=1)
-> Index lookup on o using idx_user_id (user_id=u.id)
(cost=0.25 rows=0) (actual time=0.00..0.00 rows=1 loops=10000)The output shows:
- cost=: the optimizer's estimated cost (estimate)
- rows=: the optimizer's estimated row count (estimate)
- actual time=x..y: real start..end time in milliseconds
- actual rows=: the real number of rows produced
In the example above, the optimizer estimated 0 rows per index lookup but the actual was 1 — the statistics were stale. Running ANALYZE TABLE orders; would fix the estimates.
Reading EXPLAIN — Before and After Optimization
-- Before: no index on status column EXPLAIN SELECT * FROM orders WHERE status = 'pending'G
id: 1 select_type: SIMPLE table: orders type: ALL <-- full table scan! possible_keys: NULL <-- no usable indexes key: NULL <-- no index used rows: 2500000 <-- scanning 2.5M rows filtered: 33.33 Extra: Using where
-- Fix: add a composite index for this common query pattern CREATE INDEX idx_orders_status_date ON orders (status, created_at); -- After: re-run EXPLAIN EXPLAIN SELECT * FROM orders WHERE status = 'pending' AND created_at > '2024-01-01'G
id: 1 select_type: SIMPLE table: orders type: range <-- index range scan key: idx_orders_status_date <-- using the new index key_len: 86 <-- using both columns (status + created_at) rows: 15243 <-- examining only matching rows (vs 2.5M before) filtered: 100.00 Extra: Using index condition
select_type Values
select_type | Meaning |
|---|---|
SIMPLE | Simple SELECT with no subqueries or UNIONs. |
PRIMARY | The outermost SELECT in a query with subqueries. |
SUBQUERY | A subquery in the SELECT or WHERE clause. |
DERIVED | A subquery in the FROM clause (derived table). |
DEPENDENT SUBQUERY | A correlated subquery that references columns from the outer query. |
UNION | The second or later SELECT in a UNION. |
UNION RESULT | The result of a UNION — MySQL uses a temporary table to merge results. |
MATERIALIZED | A subquery that is materialized (computed once and stored as a temp table). |
Optimizer Hints
When the optimizer makes poor choices, guide it with hints:
-- Force use of a specific index SELECT * FROM orders USE INDEX (idx_status) WHERE status = 'pending'; -- Ignore a specific index (make optimizer choose differently) SELECT * FROM orders IGNORE INDEX (idx_created_at) WHERE status = 'pending'; -- MySQL 8.0 optimizer hints (preferred over USE INDEX) SELECT /*+ INDEX(orders idx_status) */ * FROM orders WHERE status = 'pending'; -- Force a specific join order SELECT /*+ JOIN_ORDER(u, o) */ u.name, o.total FROM users u JOIN orders o ON o.user_id = u.id; -- Set max execution time to prevent runaway queries SELECT /*+ MAX_EXECUTION_TIME(3000) */ * FROM reports WHERE year = 2024;
USE INDEX / FORCE INDEX syntax. They are more expressive, self-documenting, and do not affect portability to other MySQL-compatible databases.Diagnosing Common EXPLAIN Problems
type: ALL on a large table — add an index on the column(s) in the WHERE clause.
Using filesort — add an index that covers the ORDER BY columns, or add them to the tail of a composite index.
Using temporary — consider an index covering both the GROUP BY and WHERE columns; or investigate whether DISTINCT can be removed.
Using join buffer — add an index on the join column of the inner table.
rows estimate is much higher than actual — run ANALYZE TABLE to refresh statistics.
possible_keys has good options but key is NULL — the optimizer chose a full scan because its statistics predict the index is not selective enough. Try FORCE INDEX or restructure the query.
key_len is shorter than expected for a composite index — the WHERE clause is not using all prefix columns. Recheck column ordering in the composite index.
Reading key_len to Diagnose Composite Index Usage
The key_len column tells you how many bytes of a composite index are being used. This lets you verify whether all columns of the index are actually in play:
-- Composite index: (user_id INT, status VARCHAR(20), created_at DATETIME) -- INT = 4 bytes, VARCHAR(20) utf8mb4 = up to 20*4 + 2 = 82 bytes, DATETIME = 5 bytes -- Query 1: WHERE user_id = 42 EXPLAIN SELECT * FROM orders WHERE user_id = 42G -- key_len: 4 <-- only first column used (user_id INT = 4 bytes) -- Query 2: WHERE user_id = 42 AND status = 'paid' EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid'G -- key_len: 86 <-- first two columns used (4 + 82 = 86 bytes) -- Query 3: WHERE user_id = 42 AND status = 'paid' ORDER BY created_at EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid' ORDER BY created_atG -- key_len: 86 <-- still 86 (ORDER BY uses the index structure but not reflected in key_len) -- Extra: NULL <-- no filesort! ORDER BY is handled by index
EXPLAIN for INSERT, UPDATE, DELETE
-- EXPLAIN works on DML statements too EXPLAIN UPDATE orders SET status = 'shipped' WHERE user_id = 42 AND status = 'paid'; -- EXPLAIN INSERT ... SELECT EXPLAIN INSERT INTO order_archive SELECT * FROM orders WHERE created_at < '2023-01-01'; -- EXPLAIN DELETE EXPLAIN DELETE FROM sessions WHERE expires_at < NOW();
Subquery Types in EXPLAIN
EXPLAIN breaks subqueries into separate rows. The select_type column identifies each query block:
-- Query with multiple SELECT types
EXPLAIN
SELECT u.name,
(SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u
WHERE u.id IN (SELECT user_id FROM vip_customers)G
-- Expected output:
-- id=1, select_type=PRIMARY: the outer SELECT on users
-- id=2, select_type=DEPENDENT SUBQUERY: the correlated COUNT subquery (runs once per user)
-- id=3, select_type=SUBQUERY: the IN (SELECT) on vip_customers (runs once)
-- Rewritten to avoid DEPENDENT SUBQUERY (correlated)
EXPLAIN
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
JOIN vip_customers v ON v.user_id = u.id
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.nameG
-- All rows now show SIMPLE or PRIMARY — no dependent subqueriesEXPLAIN and Partitioned Tables
-- Create a partitioned table CREATE TABLE orders_partitioned ( id INT AUTO_INCREMENT, created_at DATE NOT NULL, total DECIMAL(10,2), PRIMARY KEY (id, created_at) ) PARTITION BY RANGE (YEAR(created_at)) ( PARTITION p2022 VALUES LESS THAN (2023), PARTITION p2023 VALUES LESS THAN (2024), PARTITION p2024 VALUES LESS THAN (2025), PARTITION future VALUES LESS THAN MAXVALUE ); -- EXPLAIN shows which partitions are scanned EXPLAIN SELECT * FROM orders_partitioned WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'G -- partitions: p2024 <-- only the 2024 partition is scanned (partition pruning) -- Without partition pruning (all partitions scanned): EXPLAIN SELECT * FROM orders_partitioned WHERE total > 100G -- partitions: p2022,p2023,p2024,future
EXPLAIN with Derived Tables and CTEs
-- EXPLAIN shows derived tables as <derived2>, <derived3>, etc. EXPLAIN SELECT d.user_id, d.order_count, u.name FROM ( SELECT user_id, COUNT(*) AS order_count FROM orders GROUP BY user_id HAVING COUNT(*) > 5 ) AS d JOIN users u ON u.id = d.user_idG -- Expected output includes a row for the derived table: -- id=2, select_type=DERIVED, table=orders, type=ALL (subquery scans orders) -- id=1, select_type=PRIMARY, table=<derived2>, type=ALL (result of subquery) -- id=1, select_type=PRIMARY, table=u, type=eq_ref (join on PK) -- In MySQL 8.0+, CTEs are materialized or merged: EXPLAIN WITH top_users AS ( SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) > 10 ) SELECT u.name FROM users u WHERE u.id IN (SELECT user_id FROM top_users)G -- select_type for CTE: MATERIALIZED or SUBQUERY
Cost Estimates in EXPLAIN FORMAT=JSON
The JSON format is the only way to see the optimizer's internal cost estimates. These numbers are unit-less but comparable:
EXPLAIN FORMAT=JSON SELECT u.name, COUNT(o.id) FROM users u JOIN orders o ON o.user_id = u.id GROUP BY u.idG -- Look for: -- "query_cost": total estimated cost -- "read_cost": cost to read rows from storage -- "eval_cost": cost to evaluate conditions -- Higher cost = more work = potentially slower -- Compare two query variants by their query_cost values -- The optimizer always chooses the lower-cost plan -- If you disagree with the choice, check if statistics are stale (ANALYZE TABLE)
Common EXPLAIN Misreadings
What you see | Common Misread | Correct Interpretation |
|---|---|---|
type: index | Thinks this means an index is being used efficiently | Full index scan — reads every leaf node of the index. Only better than ALL if the index is much smaller than the table. |
rows: 1 | Assumes the query is always fast | Just an estimate. The actual row count could be very different — use EXPLAIN ANALYZE to verify. |
Extra: NULL (empty) | Assumes nothing extra is happening | Empty Extra is normal and fine — it just means no special operations like filesort or covering index are noted. |
possible_keys: many options | Assumes the optimizer uses the best one | More options in possible_keys is not always better. The optimizer picks based on cost — check the key column for what was actually chosen. |
filtered: 100.00 | Assumes this is always good | 100% filtered just means no additional WHERE filtering after the index access — could mean all rows passed, which is fine for equality lookups. |
EXPLAIN Workflow
Run EXPLAIN on the slow query and check the type column for ALL or index on large tables.
Multiply rows across all table rows to estimate total work: if table 1 returns 1000 rows and table 2 rows estimate is 500, MySQL does up to 500,000 row accesses.
Check Extra for Using filesort or Using temporary — these indicate expensive sorting/grouping operations.
Read key_len for composite indexes to confirm how many columns are actually in use.
Verify possible_keys vs key — if a good index exists but is not chosen, ANALYZE TABLE and re-check.
Add missing indexes, then re-run EXPLAIN to confirm the plan improved.
On MySQL 8.0, use EXPLAIN ANALYZE to compare optimizer estimates against actual execution and catch stale statistics.
Repeat after each change — one query improvement can reveal the next bottleneck.