Skip to content
4 min read

How to read a slow MySQL query plan

EXPLAIN tells you what MySQL decided to do. How to read the rows, type and key columns, spot the three patterns that cause most slow queries, and fix them.

On this page
  1. Find the right query first
  2. Three columns, in this order
  3. Three patterns explain most of them
  4. When the plan looks fine and it is still slow
  5. The whole method

A slow query is rarely mysterious once you stop guessing at it. MySQL will tell you exactly what it intends to do. The plan is just written in a format that rewards knowing which three columns to read first.

Find the right query first

Optimising the query somebody complained about is usually optimising the wrong one.

Turn the slow log on, let it collect, then rank by total time rather than by worst single run.

sql
SET GLOBAL slow_query_log = 'ON';
SET GLOBAL long_query_time = 0.5;          -- seconds; tune to your traffic
SET GLOBAL log_queries_not_using_indexes = 'ON';

A query taking 80ms ten thousand times an hour costs far more than a nine-second report that runs once at 3am, and only one of those two gets complained about. pt-query-digest over the log sorts by total and is almost always pointing at the real work.

Three columns, in this order

sql
EXPLAIN SELECT id, status, created_at
          FROM lead
         WHERE source = 'web'
           AND created_at >= '2026-10-01'
         ORDER BY created_at DESC
         LIMIT 20;
code
id  select_type  table  type   possible_keys  key   rows      Extra
 1  SIMPLE       lead   ALL    NULL           NULL  2418330   Using where; Using filesort

Read rows first. It is how many rows MySQL expects to examine for this step, and two million examined to return twenty is the finding. Everything else on that line is an explanation of why.

Then type, which describes how the table is reached, best to worst:

type Meaning
const / eq_ref One row, by primary or unique key
ref Rows matching an indexed value — the common good case
range An index range, as with >= or IN
index Full scan of the index
ALL Full scan of the table

ALL on a large table is the single most common cause of a slow query, and it is right there in the second column.

Then key. NULL means no index was used. If possible_keys lists candidates but key is NULL, the optimiser looked at them and decided a scan would be cheaper, which is sometimes correct and sometimes a symptom of stale statistics.

Extra holds the remaining clues. Using filesort means the result had to be sorted afterwards. Using temporary means a temporary table was built. Using index is the good one: the index answered the query without touching the rows at all.

Three patterns explain most of them

No index for the filter. The plan above wants source and created_at together.

sql
ALTER TABLE lead ADD INDEX idx_source_created (source, created_at);

Column order is the whole trick, and it is where most composite indexes go wrong. MySQL uses one left to right, so (source, created_at) serves a query filtering on source, or on source and created_at together, but does nothing for one filtering on created_at alone. Equality column first, range column second, and the same index then satisfies the ORDER BY as well, which removes the filesort for free.

A function wrapping the column. This cannot use an index on created_at:

sql
WHERE DATE(created_at) = '2026-10-01'        -- index unusable
sql
WHERE created_at >= '2026-10-01'             -- index usable
  AND created_at <  '2026-10-02'

Implicit conversions do the same damage less visibly. Comparing a VARCHAR column to a number makes MySQL convert every row before comparing, which is a full scan wearing a disguise.

Deep pagination. LIMIT 20 OFFSET 100000 reads 100,020 rows and discards 100,000 of them, and it gets worse the further anyone pages.

sql
SELECT id, status FROM lead
 WHERE source = 'web' AND created_at < :last_seen_created_at
 ORDER BY created_at DESC
 LIMIT 20;

Keyset pagination carries the last row's sort value forward rather than counting from the start, so the cost stops depending on how deep the user has gone.

When the plan looks fine and it is still slow

Switch from intention to measurement.

sql
EXPLAIN ANALYZE SELECT ...;

That runs the query and reports actual rows and actual time per step. The signal you want is disagreement: if the plan estimated 200 rows and the measurement says 400,000, the optimiser chose its plan from bad information. ANALYZE TABLE lead; refreshes the statistics, and the plan frequently changes on its own afterwards without you touching the query.

Do all of this against a realistic copy of the data. An index that looks decisive against ten thousand test rows can be ignored entirely against ten million, because the optimiser's choice depends on how selective the condition actually is. That makes a safe way to copy production to a laptop a prerequisite for this work rather than a convenience.

The whole method

It is short enough to keep in your head, which is most of why it works.

Rank by total time. Read rows, then type, then key. Fix the one pattern the plan is pointing at.

Most of the time it is a missing or badly ordered composite index, a function applied to an indexed column, or pagination counting from the beginning. All three are visible in the plan before you change a line, and all three stay invisible for as long as you optimise by intuition.

Frequently asked

How do I find slow queries in MySQL?

Turn on the slow query log with a threshold low enough to catch real offenders, then aggregate it with a tool such as pt-query-digest so you see queries ranked by total time rather than by worst single run. The query that runs ten thousand times at 80ms usually matters more than the nightly report that takes nine seconds.

What does type ALL mean in EXPLAIN?

It means MySQL plans to read every row in the table for that step — a full table scan. On a small lookup table that is fine and often optimal. On a large table it is the most common reason a query is slow, and it usually means no usable index exists for the conditions in the query.

Why is MySQL not using my index?

Common reasons: the query filters on a column that is not leftmost in a composite index, a function or type conversion is applied to the column so the index cannot be used, the condition matches too large a share of the table for an index to be worthwhile, or the statistics are stale. Running ANALYZE TABLE fixes the last one.

What is the difference between EXPLAIN and EXPLAIN ANALYZE?

EXPLAIN shows the plan the optimiser intends to use, with estimated row counts. EXPLAIN ANALYZE actually runs the query and reports measured time and actual rows per step. When the estimate and the measurement disagree sharply, the statistics are misleading the optimiser, which is a different problem from a missing index.

Found this useful?Share on XShare on LinkedIn

Related reading