SQL EXPLAIN and Query Optimization
When a query is slow, guessing does not help: ask the database how it runs the query. EXPLAIN shows the plan MySQL chose: which tables it reads in which order, which indexes it uses and how many rows it expects. This lesson reads real plans column by column and turns them into a checklist for faster SQL.
- Read the key columns of EXPLAIN: type, key, rows, Extra
- See the actual run with EXPLAIN ANALYZE
- Fix common slow patterns
- Follow a practical optimization checklist
A first EXPLAIN
EXPLAINSELECT o.order_id, c.customer_nameFROM orders oJOIN customers c ON c.customer_id = o.customer_idWHERE o.status = 'Pending';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | o | NULL | ALL | customer_id | NULL | NULL | NULL | 1 | 100 | Using where |
| 1 | SIMPLE | c | NULL | eq_ref | PRIMARY | PRIMARY | 4 | company.o.customer_id | 1 | 100 | NULL |
- Each row of the output is one table access, in the order MySQL performs them.
ordersis read first withtype = ALL(there is no index onstatus), andfilteredestimates the share of rows that pass theWHERE.- For each such order,
customersis read withtype = eq_refthrough itsPRIMARYkey: exactly one row per lookup.
The columns that matter
| Column | Tells you |
|---|---|
table | which table this step reads |
type | how it is read (see below) |
possible_keys | indexes MySQL considered |
key | the index actually used (NULL = none) |
rows | estimated rows read in this step |
filtered | estimated % of those rows that pass the conditions |
Extra | notes such as Using where, Using index, Using filesort, Using temporary |
type, from best to worst:
| type | Meaning |
|---|---|
const / system | at most one row, found by primary key or unique key with a constant |
eq_ref | one row per row of the previous table (join on a unique key) |
ref | several rows via a non-unique index |
range | an index range (BETWEEN, >, IN, LIKE 'x%') |
index | full scan of an index |
ALL | full table scan |
const and ref
EXPLAIN SELECT * FROM employees WHERE emp_id = 105;| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | employees | NULL | const | PRIMARY | PRIMARY | 4 | const | 1 | 100 | NULL |
EXPLAIN SELECT * FROM employees WHERE dept_id = 2;| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | employees | NULL | ref | dept_id | dept_id | 5 | const | 3 | 100 | NULL |
- Looking up the primary key with a constant is
const: MySQL knows at most one row matches. dept_idhas a (foreign key) index, so the second query isrefwithkey = dept_id.
EXPLAIN ANALYZE: the real run
EXPLAIN ANALYZE executes the query and reports actual times and row counts next to the estimates, as a tree:
EXPLAIN ANALYZESELECT c.customer_name, COUNT(*) AS ordersFROM customers cJOIN orders o ON o.customer_id = c.customer_idGROUP BY c.customer_name;| EXPLAIN |
|---|
| -> Table scan on <temporary> (actual time=0.112..0.112 rows=5 loops=1) -> Aggregate using temporary table (actual time=0.111..0.111 rows=5 loops=1) -> Nested loop inner join (cost=0.7 rows=1) (actual time=0.0232..0.0317 rows=10 loops=1) -> Covering index scan on o using customer_id (cost=0.35 rows=1) (actual time=0.0061..0.0101 rows=10 loops=1) -> Single-row index lookup on c using PRIMARY (customer_id=o.customer_id) (cost=0.35 rows=1) (actual time=760e-6..800e-6 rows=1 loops=10) |
Read it from the most-indented line outwards: the innermost lines run first. Compare rows= estimates with actual ... rows=: a big difference means stale statistics (ANALYZE TABLE t; refreshes them). The timings are from the build server and change on every run.
EXPLAIN FORMAT=TREE shows the same tree without running the query.
Fix: an index for a filter
CREATE INDEX idx_orders_status ON orders (status);EXPLAIN SELECT order_id FROM orders WHERE status = 'Pending';| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ref | idx_orders_status | idx_orders_status | 82 | const | 1 | 100 | Using index |
Before the index the plan was ALL; now it is ref on idx_orders_status.
Fix: avoid filesort with an index on the ORDER BY
EXPLAIN SELECT order_id, order_date FROM orders ORDER BY order_date DESC LIMIT 3;| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | ALL | NULL | NULL | NULL | NULL | 10 | 100 | Using filesort |
CREATE INDEX idx_orders_date ON orders (order_date);EXPLAIN SELECT order_id, order_date FROM orders ORDER BY order_date DESC LIMIT 3;| id | select_type | table | partitions | type | possible_keys | key | key_len | ref | rows | filtered | Extra |
|---|---|---|---|---|---|---|---|---|---|---|---|
| 1 | SIMPLE | orders | NULL | index | NULL | idx_orders_date | 3 | NULL | 3 | 100 | Backward index scan; Using index |
- Without an index on
order_date,ExtrashowsUsing filesort: MySQL sorts all rows to find the top 3. - With the index, MySQL reads the index backwards and stops after 3 entries: no filesort.
Optimization checklist
- Select only the columns you need; avoid
SELECT *on wide tables. - Index the columns in WHERE, JOIN and ORDER BY of frequent queries; check with
EXPLAINthatkeyis not NULL. - Keep columns bare in WHERE:
order_date >= '2025-01-01', notYEAR(order_date) = 2025. - Avoid leading wildcards (
LIKE '%term') on large tables; use full-text search. - Match types: compare text columns with text, numbers with numbers.
- Filter early: put conditions in
WHERErather thanHAVINGwhen they do not need aggregates. - Use
EXISTS/ joins instead ofINon huge subquery lists; preferNOT EXISTStoNOT IN. - Paginate with keyset (
WHERE id > last_id) instead of hugeOFFSETs. - Use
UNION ALLinstead ofUNIONwhen duplicates are impossible. - Measure: use
EXPLAIN ANALYZEand the slow query log, change one thing at a time, and measure again.
Quick quiz
1. Which type is the worst?
ALL means every row of the table is read.
2. key = NULL in EXPLAIN means…
MySQL reads the table without an index.
3. What does Using filesort indicate?
An index on the ORDER BY columns can remove it.
4. What is the difference between EXPLAIN and EXPLAIN ANALYZE?
EXPLAIN only shows the estimated plan.
EXPLAIN and optimization FAQ
How do I find out why a MySQL query is slow?
Run EXPLAIN (or EXPLAIN ANALYZE) on it. Look for type = ALL on large tables, key = NULL, very high rows, and Using filesort or Using temporary in Extra.
What does EXPLAIN do in SQL?
It shows the execution plan the optimizer chose: the order of table access, the indexes used and the estimated number of rows.
How can I optimize a SQL query?
Add indexes that match the filters, joins and sorts; keep indexed columns free of functions; return only needed columns and rows; and verify every change with EXPLAIN ANALYZE.