SQL Tutorial › Performance & Objects › EXPLAIN & Query Tuning

SQL EXPLAIN and Query Optimization

AdvancedLesson 61 of 6913 min read7 queries run on MySQL 8.4

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.

Advertisement
In this lesson
  • 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

MySQL
EXPLAINSELECT o.order_id, c.customer_nameFROM orders oJOIN customers c ON c.customer_id = o.customer_idWHERE o.status = 'Pending';
ResultMySQL 8.4
idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEoNULLALLcustomer_idNULLNULLNULL1100Using where
1SIMPLEcNULLeq_refPRIMARYPRIMARY4company.o.customer_id1100NULL
2 rows in set
How this query works, step by step
  1. Each row of the output is one table access, in the order MySQL performs them.
  2. orders is read first with type = ALL (there is no index on status), and filtered estimates the share of rows that pass the WHERE.
  3. For each such order, customers is read with type = eq_ref through its PRIMARY key: exactly one row per lookup.

The columns that matter

ColumnTells you
tablewhich table this step reads
typehow it is read (see below)
possible_keysindexes MySQL considered
keythe index actually used (NULL = none)
rowsestimated rows read in this step
filteredestimated % of those rows that pass the conditions
Extranotes such as Using where, Using index, Using filesort, Using temporary

type, from best to worst:

typeMeaning
const / systemat most one row, found by primary key or unique key with a constant
eq_refone row per row of the previous table (join on a unique key)
refseveral rows via a non-unique index
rangean index range (BETWEEN, >, IN, LIKE 'x%')
indexfull scan of an index
ALLfull table scan

const and ref

MySQL
EXPLAIN SELECT * FROM employees WHERE emp_id = 105;
ResultMySQL 8.4
idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEemployeesNULLconstPRIMARYPRIMARY4const1100NULL
1 row in set
MySQL
EXPLAIN SELECT * FROM employees WHERE dept_id = 2;
ResultMySQL 8.4
idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEemployeesNULLrefdept_iddept_id5const3100NULL
1 row in set
How this query works, step by step
  1. Looking up the primary key with a constant is const: MySQL knows at most one row matches.
  2. dept_id has a (foreign key) index, so the second query is ref with key = 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:

MySQL
EXPLAIN ANALYZESELECT c.customer_name, COUNT(*) AS ordersFROM customers cJOIN orders o ON o.customer_id = c.customer_idGROUP BY c.customer_name;
ResultMySQL 8.4
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)
1 row in set

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

MySQL
CREATE INDEX idx_orders_status ON orders (status);EXPLAIN SELECT order_id FROM orders WHERE status = 'Pending';
ResultMySQL 8.4
mysql> CREATE INDEX idx_orders_status ON orders (status)
Query OK, 0 rows affected
mysql> EXPLAIN SELECT order_id FROM orders WHERE status = 'Pending'
idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEordersNULLrefidx_orders_statusidx_orders_status82const1100Using index
1 row in set

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

MySQL
EXPLAIN SELECT order_id, order_date FROM orders ORDER BY order_date DESC LIMIT 3;
ResultMySQL 8.4
idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEordersNULLALLNULLNULLNULLNULL10100Using filesort
1 row in set
MySQL
CREATE INDEX idx_orders_date ON orders (order_date);EXPLAIN SELECT order_id, order_date FROM orders ORDER BY order_date DESC LIMIT 3;
ResultMySQL 8.4
mysql> CREATE INDEX idx_orders_date ON orders (order_date)
Query OK, 0 rows affected
mysql> EXPLAIN SELECT order_id, order_date FROM orders ORDER BY order_date DE…
idselect_typetablepartitionstypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEordersNULLindexNULLidx_orders_date3NULL3100Backward index scan; Using index
1 row in set
How this query works, step by step
  1. Without an index on order_date, Extra shows Using filesort: MySQL sorts all rows to find the top 3.
  2. With the index, MySQL reads the index backwards and stops after 3 entries: no filesort.

Optimization checklist

  1. Select only the columns you need; avoid SELECT * on wide tables.
  2. Index the columns in WHERE, JOIN and ORDER BY of frequent queries; check with EXPLAIN that key is not NULL.
  3. Keep columns bare in WHERE: order_date >= '2025-01-01', not YEAR(order_date) = 2025.
  4. Avoid leading wildcards (LIKE '%term') on large tables; use full-text search.
  5. Match types: compare text columns with text, numbers with numbers.
  6. Filter early: put conditions in WHERE rather than HAVING when they do not need aggregates.
  7. Use EXISTS / joins instead of IN on huge subquery lists; prefer NOT EXISTS to NOT IN.
  8. Paginate with keyset (WHERE id > last_id) instead of huge OFFSETs.
  9. Use UNION ALL instead of UNION when duplicates are impossible.
  10. Measure: use EXPLAIN ANALYZE and the slow query log, change one thing at a time, and measure again.

Quick quiz

1. Which type is the worst?

2. key = NULL in EXPLAIN means…

3. What does Using filesort indicate?

4. What is the difference between EXPLAIN and EXPLAIN ANALYZE?

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.