EXPLAIN ANALYZE: Learning to Really Read Query Plans
AI generated
InnoDB
SQL
MySQL · Query Optimization · EXPLAIN · Performance
EXPLAIN ANALYZE
Learning to really read query plans

EXPLAIN shows what the optimizer plans, EXPLAIN ANALYZE shows what actually happened. Anyone who correctly interprets type, key, rows and Extra, and compares estimated rows against real execution statistics, finds full table scans and filesorts before they become a problem in production.

16 min read EXPLAIN ANALYZE · Query Plan · Filesort MySQL 8.0 · InnoDB

1. Why reading query plans is a core skill

A query plan is the optimizer's decision on how to execute a query: which indexes are used, in what order tables are joined, and whether sorting happens inside the index or afterward in memory. Anyone who does not read EXPLAIN optimizes queries by gut feeling, and gut feeling regularly misses the mark once data volumes reach the millions. The query plan is the only reliable source of what MySQL actually intends to do for a given query.

In practice, the difference between an index access hitting a few hundred rows and a full table scan over millions of rows decides milliseconds versus seconds of response time. That decision is visible in the query plan long before it becomes a problem in production. The following sections show how to read the classic EXPLAIN output, how EXPLAIN ANALYZE additionally provides real execution numbers, and how to systematically derive optimizations from that.

The critical mistake when reading query plans is treating EXPLAIN as pure confirmation ("an index is used, so everything is fine") instead of as a diagnostic tool. Using an index is necessary but not sufficient for a fast query. Only the interplay of type, rows, and Extra shows whether the query plan is actually efficient.

2. The classic EXPLAIN output: type, key and rows

The type column in the query plan shows the access method, sorted from fast to slow: const, eq_ref, ref, range, index, ALL. const means at most one row is found via a primary or unique key, usually for an ID lookup. ALL means a full table scan where every row is checked. In between sit ref for index accesses with an equality condition and range for range queries over an index.

The key column shows which index was actually used, as opposed to possible_keys, which only lists the theoretically eligible indexes. An empty key value despite a populated possible_keys is a clear warning sign: the optimizer recognized an index as possible but decided against using it, usually because the statistics estimate a full table scan as cheaper.

The rows column is an estimate, not an exact number, based on the table statistics. It states how many rows the optimizer has to search through for this step of the query plan before filter conditions are applied. A low rows value in a JOIN across several tables is critical, because the values can multiply across nested loops and an innocent looking number can quickly become a million-row problem.


EXPLAIN SELECT o.id, c.email FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 2 AND o.created_at > '2026-01-01'\G

*************************** 1. row ***************************
           id: 1
  select_type: SIMPLE
        table: o
         type: range
possible_keys: idx_status_created,idx_customer_id
          key: idx_status_created
      key_len: 4
          ref: NULL
         rows: 1840
        Extra: Using index condition
*************************** 2. row ***************************
           id: 1
  select_type: SIMPLE
        table: c
         type: eq_ref
possible_keys: PRIMARY
          key: PRIMARY
      key_len: 4
          ref: shop.o.customer_id
         rows: 1
        Extra: NULL

3. Correctly interpreting join types

In the query plan of a JOIN, a separate row appears for each involved table, and the order of these rows shows the actual processing order, which does not necessarily match the order in the SQL statement. The optimizer typically chooses to search the table with the lowest estimated row count first and then jump into the next table via an index. An eq_ref for the second table means that for each row of the first table exactly one matching row is found via a unique or primary key, which is a very efficient join type.

A query plan becomes problematic when a later table in the join shows type: ALL, because then a complete scan of that table is performed for every row of the previous table, which compounds multiplicatively. With ten thousand rows from the first table and a full table scan over a hundred thousand rows in the second, the worst case produces billions of checked combinations, even if the final result only has a handful of rows.

The optimizer fundamentally decides the join order itself and ignores the order of tables in the FROM and JOIN clauses of the SQL statement. If you see an unexpected order in the query plan, do not interpret it as a bug but as the result of the optimizer's cost estimate, which in turn depends on current table statistics.

4. Decoding the Extra column

The Extra column contains the most important warning signals in the entire query plan. Using filesort means MySQL cannot deliver the result set sorted via an index and instead performs a separate sort operation in memory or on disk. Using temporary means a temporary table is created for GROUP BY, DISTINCT, or complex subqueries, which causes significant overhead for large intermediate results.

Using index, on the other hand, is a positive signal: all needed columns are read directly from the index without touching the actual table. This is the so called covering index, covered in depth in a dedicated article. Using where shows that after the index access an additional filter condition is checked in the storage engine or server layer, which can mean many unnecessarily read rows when the index has low selectivity.

Multiple entries in the Extra column, separated by semicolons, are normal and describe consecutive processing steps for the same table in the query plan. The combination Using where; Using index, for example, means both a covering index and an additional filter condition are applied inside the index, without the actual table needing to be read.


EXPLAIN SELECT customer_id, COUNT(*) FROM orders
WHERE status = 2
GROUP BY customer_id
ORDER BY COUNT(*) DESC\G

*************************** 1. row ***************************
        table: orders
         type: ref
          key: idx_status
         rows: 48210
        Extra: Using where; Using temporary; Using filesort
-- Using temporary: a temp table is built for GROUP BY
-- Using filesort: the aggregated result is sorted separately afterwards

5. EXPLAIN ANALYZE: real execution statistics

The decisive difference between EXPLAIN and EXPLAIN ANALYZE: EXPLAIN shows a plan that is never executed, while EXPLAIN ANALYZE actually executes the query and measures the real runtimes and row counts per step of the query plan. Since MySQL 8.0.18, EXPLAIN ANALYZE provides a tree shaped output with actual time, giving the actual start time and total duration of each operator in milliseconds, and actual rows for the row count that was really processed.

Because EXPLAIN ANALYZE really executes the query, it should never be used uncontrolled on production UPDATE or DELETE statements without being aware of the side effects. For SELECT queries the risk is low, but with very large result sets EXPLAIN ANALYZE can itself generate noticeable load, because the entire query runs to completion, even if the original query plan only actually asked for a LIMIT of ten rows.


EXPLAIN ANALYZE
SELECT o.id, c.email FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 2 AND o.created_at > '2026-01-01';

-> Nested loop inner join  (cost=612.40 rows=1840) (actual time=0.089..4.812 rows=1793 loops=1)
    -> Index range scan on o using idx_status_created
       (cost=201.20 rows=1840) (actual time=0.061..1.204 rows=1840 loops=1)
    -> Single-row index lookup on c using PRIMARY (id=o.customer_id)
       (cost=0.25 rows=1) (actual time=0.001..0.001 rows=1 loops=1840)

6. Estimated versus actual rows: spotting statistics drift

The most valuable comparison when reading EXPLAIN ANALYZE is estimated versus actual row count. If both values differ by more than a factor of ten, this is called statistics drift: the table statistics used by the optimizer are stale, and the chosen query plan is based on wrong assumptions about data distribution. This often leads to a suboptimal join order or the wrong choice between index access and full table scan.

Statistics drift typically arises after large bulk inserts, after deleting large amounts of data, or simply through the persistent nature of InnoDB statistics, which are not automatically recalculated on every change by default. An ANALYZE TABLE after significant data changes refreshes the statistics and is often the simplest fix when EXPLAIN ANALYZE shows a marked deviation between estimated and actual row counts.


-- Before: optimizer estimate is far off from reality
EXPLAIN ANALYZE SELECT id FROM orders WHERE status = 2;
-> Index range scan on orders using idx_status
   (cost=201.20 rows=1840) (actual time=0.05..38.220 rows=48210 loops=1)
-- estimated 1840 rows, actually 48210 -- statistics drift, factor 26

ANALYZE TABLE orders;
-- Table statistics rebuilt from a fresh sample of the table

-- After: estimate matches reality much more closely
EXPLAIN ANALYZE SELECT id FROM orders WHERE status = 2;
-> Index range scan on orders using idx_status
   (cost=4820.10 rows=47990) (actual time=0.05..38.010 rows=48210 loops=1)

7. Hunting down full table scans and filesorts

A systematic way to find problematic query plans in an existing application goes through the slow query log combined with pt-query-digest or the performance schema table events_statements_summary_by_digest. Both identify the most frequent and most expensive queries, which you then examine specifically with EXPLAIN ANALYZE. Full table scans show up as type: ALL combined with a high rows number, filesorts as Using filesort in the Extra column.

Not every full table scan is a problem: for small lookup tables with a few hundred rows, a scan is often faster than the overhead of an index access. The decisive question is always the absolute row count combined with query frequency. A filesort over twenty rows in a rarely executed admin query is uncritical, the same filesort over a million rows on every product page request of a shop is an acute performance killer.

A pragmatic threshold for initial prioritization: queries examining more than ten thousand rows according to rows, combined with more than a hundred executions per minute according to the performance schema, deserve first attention. Everything below that can usually wait until the bigger issues in the query plan are fixed.

8. EXPLAIN FORMAT=JSON for deep analysis

For complex query plans with multiple subqueries, derived tables, or window functions, EXPLAIN FORMAT=JSON provides additional details missing from the classic table form, such as estimated cost per step (cost_info), the sort algorithms used, and the exact structure of nested queries. Combined with ANALYZE as EXPLAIN ANALYZE FORMAT=JSON, you get the most complete available view of a query plan, including real runtimes per nested element.

The JSON format is especially suited for automated evaluation, for example when query plans need to be checked against thresholds for rows or cost in CI pipelines, to catch regressions from new migrations early, before a query becomes slow in production.


EXPLAIN FORMAT=JSON SELECT id FROM orders WHERE status = 2\G
{
  "query_block": {
    "select_id": 1,
    "cost_info": { "query_cost": "4820.10" },
    "table": {
      "table_name": "orders",
      "access_type": "ref",
      "possible_keys": ["idx_status"],
      "key": "idx_status",
      "rows_examined_per_scan": 47990,
      "filtered": "100.00"
    }
  }
}

9. Practical workflow: from slow query to fix

The practical workflow starts with the slow query log to identify the query with the highest cumulative runtime. Next comes EXPLAIN ANALYZE for the exact query with realistic parameters, since a query plan can differ depending on the values passed in. First check type for ALL or index as a warning sign, then Extra for Using filesort or Using temporary, and finally compare estimated with actual rows for statistics drift.

After diagnosis comes the targeted fix: a new composite index, a query rewrite, or ANALYZE TABLE for stale statistics. The fix is verified again with EXPLAIN ANALYZE, to make sure the actual runtime improved, not just that the theoretical query plan looks better. This repetition matters because a new index can, in rare cases, produce a different, equally suboptimal plan.

For quick prioritization, an overview of the most important signals in the query plan helps, sorted by severity.

Signal in the query plan Meaning Severity Typical action
type: ALL Full table scan High on large tables Add a matching composite index
Using filesort Sort happening outside the index Medium to high Align index order with ORDER BY
Using temporary Temporary table for GROUP BY/DISTINCT Medium to high Index the GROUP BY columns
key: NULL despite possible_keys Index discarded despite being eligible Medium ANALYZE TABLE, check statistics
Using index Covering index, no table access Positive None, keep as is

These signals do not replace a full analysis but give a quick first assessment of which queries from a long slow query log should be examined first. Combined with EXPLAIN ANALYZE and actual row counts, a complete picture of the query plan emerges.

It is also important to re-check the query plan after every schema change. A new composite index can speed up existing queries but can also cause the optimizer to suddenly pick a less favorable plan for a different query, because the relative costs between several possible indexes have shifted. A recurring look at EXPLAIN ANALYZE after every index change belongs to the workflow just as much as the initial diagnosis.

Mironsoft

Query analysis, EXPLAIN audits, and MySQL performance tuning

Query plans nobody on your team actually reads?

We analyze your slowest queries with EXPLAIN ANALYZE, uncover full table scans and filesorts, and translate the results into concrete index and query changes.

Slow query analysis

Prioritize the most expensive queries from logs and performance schema

EXPLAIN workshop

Your team learns to read and assess query plans themselves

Statistics monitoring

Automated checks for statistics drift and stale plans

10. Summary

The query plan is the only reliable source for how MySQL actually executes a query. type, key, and rows show the access method and estimated row count, while the Extra column with Using filesort and Using temporary exposes the most expensive additional operations. EXPLAIN ANALYZE supplements these estimates with real runtimes and actual row counts per step, and makes statistics drift visible.

The systematic workflow of slow query log, EXPLAIN ANALYZE, and targeted action, followed by re-verification, turns reading query plans from an art into a repeatable process. Anyone who confidently interprets type, key, rows, and Extra finds full table scans and filesorts long before they cause timeouts in production.

In the end, the habit that pays off most is running every new or changed query through EXPLAIN ANALYZE once before deployment. A query plan that looks unremarkable in a development system with a few thousand test rows can behave completely differently at millions of rows in production, especially when statistics or data distribution diverge significantly.

EXPLAIN ANALYZE, reading query plans: the essentials

Check type and key

type: ALL and an empty key despite possible_keys are the first warning signs in a query plan.

Read the Extra column

Using filesort and Using temporary show expensive extra operations outside the index.

Use EXPLAIN ANALYZE

Real actual time and actual rows instead of pure estimates, but with care on production writes.

Spot statistics drift

Fix large deviations between estimated and actual rows with ANALYZE TABLE.

11. FAQ: EXPLAIN ANALYZE and query plans

1Difference between EXPLAIN and EXPLAIN ANALYZE?
EXPLAIN shows a theoretical plan without execution. EXPLAIN ANALYZE executes the query and delivers real runtimes and row counts.
2Is EXPLAIN ANALYZE safe in production?
Usually safe for SELECT, but very large result sets can generate noticeable load since the query runs to completion.
3What does type: ALL mean?
A full table scan, every row is checked. On large tables usually a sign of a missing index.
4What does Using filesort mean?
Sorting happens outside the index, in memory or on disk, instead of directly from index order.
5Why does the optimizer sometimes skip an index?
Stale statistics or low selectivity make a full table scan look cheaper than the index access.
6What is statistics drift?
A large deviation between estimated and actual rows, fixed with ANALYZE TABLE.
7What is EXPLAIN FORMAT=JSON for?
Provides cost information per step and the exact structure of nested queries in addition to the table form.
8eq_ref versus ref?
eq_ref returns exactly one row via a primary or unique key, ref can return several rows via a regular index.
9How do I find the most expensive queries?
Evaluate the slow query log with pt-query-digest or events_statements_summary_by_digest in the performance schema.
10Is a full table scan always bad?
No, on small tables often faster than an index access. Row count and query frequency are what matter.