How to read MySQL EXPLAIN and EXPLAIN ANALYZE to fix a slow query
A hands-on walkthrough of MySQL's EXPLAIN, FORMAT=TREE and EXPLAIN ANALYZE on MySQL 8.4 and 9.7 LTS: which columns matter, how to read the iterator tree, and how a composite index removes a full scan and a filesort.
Updated 10 min read

In this article
A page that took 40 milliseconds in development takes four seconds in production, and the only thing that changed is the amount of data. Almost every MySQL performance problem an application developer meets starts this way: a query that was fine on a few thousand rows is now reading millions of them. The fix is usually an index or a small rewrite, but guessing which one wastes time and can make things worse.
MySQL tells you exactly how it plans to run a statement through EXPLAIN, and how it actually ran it through EXPLAIN ANALYZE. This tutorial walks through both on MySQL 8.4 LTS and 9.7 LTS (the syntax shown also works on the 26.7 Innovation release), using a realistic orders table. You will learn which columns matter, how to read the tree format, and a repeatable loop for turning a slow query into a fast one.
Everything here was checked against the MySQL 9.7 Reference Manual in October 2026. Where output is shown, numbers are replaced with ... because the exact costs and timings depend on your data and hardware.
Set up a table worth explaining
Use a scratch schema on a development server, never production. The table below is typical of a small SaaS or shop: an auto-increment primary key, a foreign key to customers, a status and a timestamp.
CREATE TABLE orders (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
customer_id BIGINT UNSIGNED NOT NULL,
status VARCHAR(20) NOT NULL,
total_cents INT UNSIGNED NOT NULL,
created_at DATETIME NOT NULL,
PRIMARY KEY (id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;Load enough rows to make the optimizer's choices visible: a few hundred thousand is plenty. A recursive common table expression is a quick way to generate them without external tools:
SET SESSION cte_max_recursion_depth = 500000;
INSERT INTO orders (customer_id, status, total_cents, created_at)
WITH RECURSIVE seq (n) AS (
SELECT 1 UNION ALL SELECT n + 1 FROM seq WHERE n < 300000
)
SELECT
1 + (n % 5000),
ELT(1 + (n % 4), 'pending', 'paid', 'shipped', 'refunded'),
100 + (n % 90000),
NOW() - INTERVAL (n % 730) DAY
FROM seq;
ANALYZE TABLE orders;ANALYZE TABLE refreshes the index statistics the optimizer uses for its row estimates. Run it after large loads so the plans you see reflect the real data.
The query we will tune is the one behind a customer's "recent orders" screen:
SELECT id, status, total_cents, created_at
FROM orders
WHERE customer_id = 42
AND status = 'paid'
ORDER BY created_at DESC
LIMIT 20;Read the traditional EXPLAIN output
Prefix the statement with EXPLAIN. The query is not executed; MySQL only plans it. The default format is TRADITIONAL, a table with one row per table access. With no index on customer_id, you will see something like this (abridged):
EXPLAIN SELECT id, status, total_cents, created_at
FROM orders
WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20\G
id: 1
select_type: SIMPLE
table: orders
type: ALL
possible_keys: NULL
key: NULL
rows: ...
filtered: ...
Extra: Using where; Using filesortNot every column deserves equal attention. These are the ones to read first:
| Column | What it tells you | Warning sign |
|---|---|---|
type | The join (access) type: how rows are found. | ALL (full table scan) or index (full index scan) on a large table. |
possible_keys / key | Indexes the optimizer considered, and the one it chose. | NULL in key for a selective WHERE. |
key_len | How many bytes of the chosen index are used, which shows how many columns of a composite index are in play. | Shorter than expected: later index columns are not being used. |
rows | Estimated rows to examine. | Far more than the rows you return. |
filtered | Estimated percentage of examined rows that survive the table condition. | Low values mean most of the reading is wasted. |
Extra | Additional steps. | Using filesort, Using temporary on hot queries. |
The manual lists access types from best to worst: system, const, eq_ref, ref, fulltext, ref_or_null, index_merge, unique_subquery, index_subquery, range, index and ALL. For a lookup by a non-unique column you want ref; for a date window, range is normal.
In Extra, Using index is good news: the columns came from the index tree alone, without reading the full row. Using index condition means index condition pushdown is filtering index entries before rows are fetched. Using filesort means MySQL needs an extra pass to sort, and Using temporary means it built a temporary table, typically for GROUP BY or DISTINCT.

Switch to the tree format
The table format flattens the plan. FORMAT=TREE shows the actual iterator structure the executor will run, nested from the outermost operation inwards. The manual notes that it is the only format that shows hash join usage, and it is the format EXPLAIN ANALYZE uses by default.
EXPLAIN FORMAT=TREE
SELECT id, status, total_cents, created_at
FROM orders
WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20\G
EXPLAIN: -> Limit: 20 row(s) (cost=... rows=20)
-> Sort: orders.created_at DESC, limit input to 20 row(s) per chunk (cost=... rows=...)
-> Filter: ((orders.customer_id = 42) and (orders.status = 'paid')) (cost=... rows=...)
-> Table scan on orders (cost=... rows=...)Read it from the deepest line upwards: scan every row, filter, sort, then keep 20. The whole table is read to return 20 rows, which is exactly the problem.
If you prefer this format all the time, set the explain_format system variable for your session: SET @@explain_format = TREE;. Valid values are TRADITIONAL (the default), JSON and TREE. For tooling, FORMAT=JSON is machine-readable; since MySQL 9.5 the default explain_json_format_version is 2, an access-path-based layout that matches the tree.
Measure with EXPLAIN ANALYZE
EXPLAIN shows estimates. EXPLAIN ANALYZE actually runs the statement and adds measurements to each iterator: the time to the first row, the time per loop in milliseconds, the actual number of rows and the number of loops. It works with SELECT, multi-table UPDATE and DELETE, and TABLE statements.
EXPLAIN ANALYZE
SELECT id, status, total_cents, created_at
FROM orders
WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20\G
-> Limit: 20 row(s) (cost=... rows=20) (actual time=... rows=20 loops=1)
-> Sort: orders.created_at DESC, limit input to 20 row(s) per chunk
(cost=... rows=...) (actual time=... rows=20 loops=1)
-> Filter: ((orders.customer_id = 42) and (orders.status = 'paid'))
(cost=... rows=...) (actual time=... rows=... loops=1)
-> Table scan on orders (cost=... rows=...) (actual time=... rows=300000 loops=1)Two habits make this output useful:
- Compare estimated
rowswith actualrows. A large gap means the optimizer is working from bad statistics or cannot estimate a condition well. RunANALYZE TABLE, and consider a histogram for unindexed columns used in filters. - Multiply by
loops. The actual time is an average per loop. An inner iterator in a nested-loop join that takes 0.05 ms but runs 200,000 times is the real cost.
Because EXPLAIN ANALYZE executes the query, be careful on production: it takes the same time and locks as the real statement. It can be stopped with KILL QUERY or Ctrl-C. It cannot be combined with FOR CONNECTION, and FORMAT=TRADITIONAL is rejected.
Fix the query with the right composite index
The query filters on two equality conditions and sorts by a third column. A composite index that puts the equality columns first and the sort column last lets MySQL jump straight to the matching rows and read them already in order:
ALTER TABLE orders
ADD INDEX idx_orders_customer_status_created (customer_id, status, created_at);This follows the leftmost-prefix rule documented in the manual: an index on (col1, col2, col3) can serve lookups on (col1), (col1, col2) and (col1, col2, col3). Because both customer_id and status are constants in the WHERE clause, the remaining entries are already in created_at order and no sort is needed. Run the analysis again:
EXPLAIN ANALYZE
SELECT id, status, total_cents, created_at
FROM orders
WHERE customer_id = 42 AND status = 'paid'
ORDER BY created_at DESC LIMIT 20\G
-> Limit: 20 row(s) (cost=... rows=20) (actual time=... rows=20 loops=1)
-> Index lookup on orders using idx_orders_customer_status_created
(customer_id=42, status='paid') (reverse) (cost=... rows=...)
(actual time=... rows=20 loops=1)The sort has gone, the table scan has gone, and the lookup stops after 20 rows. In the traditional format, type becomes ref, key names the new index, and Extra shows Backward index scan instead of Using filesort.
Go one step further: a covering index
The query still reads total_cents from the clustered index (the row). If this query is very hot, add that column to the end of the index so every selected column comes from the index itself. id does not need adding: in InnoDB every secondary index entry already contains the primary key columns. Then Extra shows Using index. Only do this for queries that justify it; every extra index column costs space and slows writes.

Common patterns that defeat an index
If EXPLAIN still shows a full scan after you add an index, look for one of these:
- A function wrapped around the column.
WHERE DATE(created_at) = '2026-10-01'cannot use an index oncreated_at. Rewrite as a range:created_at >= '2026-10-01' AND created_at < '2026-10-02', or create a functional index such asINDEX ((DATE(created_at)))(note the double parentheses). - Skipping the leading column. An index on
(customer_id, status)does not helpWHERE status = 'paid'alone. - An
ORacross different columns.WHERE customer_id = 42 OR status = 'paid'cannot use a single composite index; the optimizer may use index merge or scan. AUNIONof two indexed queries is often faster. - Leading wildcards.
LIKE '%term'cannot use a B-tree index;LIKE 'term%'can. - Type or collation mismatches in joins. Joining a
VARCHARto anINT, or two string columns with different collations, can prevent index use on one side. - Low selectivity. If the condition matches a large share of the table, a scan may genuinely be cheaper and the optimizer is right to choose it.
Explain from inside your application
Two features help when the slow query lives in application code rather than a console.
Capture the plan as JSON. EXPLAIN FORMAT=JSON INTO @var stores the plan in a user variable, which you can inspect with JSON functions, for example in an automated check that a critical query still uses its index:
EXPLAIN FORMAT=JSON INTO @plan
SELECT id FROM orders WHERE customer_id = 42 AND status = 'paid';
SELECT JSON_EXTRACT(@plan, '$.query_block.table.key') AS chosen_key;The JSON path depends on explain_json_format_version; the example above uses version 1 paths. With version 2 (the default since 9.5) the structure follows the access-path tree, so inspect the document once before writing paths into a test.
Explain a statement that is running now. Find the connection ID in SHOW PROCESSLIST, then run EXPLAIN FOR CONNECTION 1234;. You need the PROCESS privilege to explain another user's connection.
A repeatable tuning loop
- Find the expensive statements first. The
sys.statement_analysisview and the slow query log show which queries take the most total time; tune those, not the ones that look ugly. - Copy the statement with real parameter values and run
EXPLAIN FORMAT=TREE. - Run
EXPLAIN ANALYZEon a production-sized copy and compare estimated with actual rows. - Change one thing: an index, a rewrite, or fresh statistics.
- Re-run
EXPLAIN ANALYZEand confirm the change removed the expensive step. - Ship the index through your normal reviewed migration process, and watch write latency afterwards.
Next steps
EXPLAIN tells you what MySQL will do; EXPLAIN ANALYZE tells you what it did and where the time went. Read the tree from the inside out, compare estimates with reality, and design composite indexes around equality columns first, then range or sort columns. Our companion guide to composite, covering and invisible indexes goes deeper into index design, and the manual's optimization chapter is the authoritative reference for every access type and Extra value.
Frequently asked questions
Does EXPLAIN run my query?
No. Plain
EXPLAINonly shows the plan.EXPLAIN ANALYZEdoes execute the statement and reports actual timings, so treat it like running the query itself.Which EXPLAIN format should I use?
Use
FORMAT=TREEto understand the plan,EXPLAIN ANALYZEto measure it andFORMAT=JSONwhen a tool or test needs to parse it. The traditional table remains the default.Why does MySQL ignore my index?
Common causes are a function applied to the indexed column, a condition that skips the index's leading column, an
ORacross columns, a leading wildcard inLIKE, or a condition matching so many rows that a scan is cheaper. Stale statistics can also mislead the optimizer; runANALYZE TABLE.Is EXPLAIN ANALYZE available in MySQL 8.4?
Yes. It is available in 8.4 LTS, 9.7 LTS and the 26.7 Innovation release. Some options, such as
EXPLAIN ANALYZE FORMAT=JSON INTO, need JSON format version 2.
Your next customer could start right here.
Introduce your tool, product or business to readers exploring technology.
Explore placements and request a private quote
Sources
- MySQL 9.7 Reference Manual: EXPLAIN Statement — Oracle (MySQL documentation)
- MySQL 9.7 Reference Manual: EXPLAIN Output Format — Oracle (MySQL documentation)
- MySQL 9.7 Reference Manual: Multiple-Column Indexes — Oracle (MySQL documentation)
- MySQL 9.7 Reference Manual: ORDER BY Optimization — Oracle (MySQL documentation)
- MySQL 9.7 Reference Manual: Clustered and Secondary Indexes — Oracle (MySQL documentation)
- MySQL 9.7 Reference Manual: CREATE INDEX Statement — Oracle (MySQL documentation)
- MySQL 9.7 Reference Manual: The statement_analysis View — Oracle (MySQL documentation)
- MySQL Community Server downloads — Oracle (MySQL documentation)
Written with substantial AI assistance. Facts checked against cited sources. Editorial policy.
Comments
No comments yet. Be the first to join the conversation.
Leave a comment
Every comment is read by a moderator before it appears. Your email address is never published; it is used only to contact you about this comment.
More from Cloudsoft Zone

ComparisonMySQL
MySQL 8.4 LTS, 9.7 LTS or 26.7 Innovation: choosing a version and upgrading
MySQL now ships LTS and Innovation releases with calendar version numbers. Compare 8.4 LTS, 9.7 LTS and 26.7 Innovation, see the supported upgrade paths, and follow a checklist for moving an application from 8.4 to 9.7.
By Cloudsoft Zone

Resource guideWordPress
WordPress security hardening checklist for 2026 (WordPress 7.1)
A practical WordPress 7.1 hardening checklist based on the official handbook: updates, two-step logins, wp-config.php constants, file permissions, database privileges, firewalls, WP-CLI checksum checks and tested backups.
By Cloudsoft Zone

TutorialWordPress
How to update WordPress safely with WP-CLI: backup, update, verify, roll back
A repeatable WordPress update routine with WP-CLI: check pending updates, export the database and archive files, update core, plugins, themes and translations in order, verify checksums, and restore if something breaks.
By Cloudsoft Zone
