AI InterviewSearch…Search problems, topics, or pages…Ctrl KGet started
Skip to content

13 Indexes and performance / Lesson 3 of 5

Reading a query plan with EXPLAIN

16 min · +10 XP · 2 exercises · 5 questions

Read the text16 min
Exercises0/2
Quiz5 q

Quick answer

How do you read MySQL EXPLAIN output?

Read MySQL EXPLAIN one row per table access, in join order. The type column says how rows are found, from const and eq_ref (one row by key) through ref and range down to index and ALL (full scans). The key column is the index chosen, rows is the estimated number examined, and Extra flags extra work such as Using filesort or Using temporary.

An index you created is a hope; a query plan is the server telling you what it will actually do. Every performance conversation — in a code review, an incident or an interview — ends with "what does EXPLAIN say?". This lesson teaches MySQL 8's plan output column by column, the access-type ladder you should know by heart, the Extra notes that signal trouble, and EXPLAIN ANALYZE, which runs the query and shows the real numbers.

The exercise engine here is SQLite, whose EXPLAIN QUERY PLAN looks nothing like MySQL's, so this lesson is reading: the plans below are MySQL's, and the exercises analyse plan rows stored in a table.

Asking for a plan

Put EXPLAIN in front of a SELECT (or an UPDATE, DELETE, INSERT … SELECT). The query is planned, not run.

EXPLAIN
SELECT o.id, o.amount
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.city = 'Pune'
ORDER BY o.amount DESC;
idselect_typetabletypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEcrefPRIMARY,idx_cityidx_city243const120100.00Using index; Using temporary; Using filesort
1SIMPLEorefidx_orders_customeridx_orders_customer4shop.c.id9100.00NULL

One row per table access, listed in the join order the optimizer chose: here it starts from the Pune customers and, for each, looks up their orders.

The columns

  • id and select_type — which SELECT the row belongs to: SIMPLE for a query without subqueries or unions, PRIMARY for the outer one, SUBQUERY, DERIVED (a derived table in FROM), UNION.
  • table — the table or alias accessed in this step.
  • type — how rows are found. The most important column; see the ladder below.
  • possible_keys — indexes that could apply. NULL here means no index matches the predicates at all.
  • key — the index actually chosen, or NULL for none.
  • key_len — how many bytes of the index key are used: on a composite index, a short key_len tells you only the first column was used to seek.
  • ref — what the key is compared with: const for a literal, db.table.column for a join column.
  • rows — the estimated number of rows examined in this step, per lookup. For a nested-loop join, multiply the steps' rows (and filtered) to estimate the total work.
  • filtered — the estimated percentage of those rows that survive the remaining conditions.
  • Extra — everything else, and often the most revealing part.

The access-type ladder

From best to worst — memorise the order:

typeMeaning
system / constat most one matching row, found by a primary key or unique index compared with a constant; read once
eq_refin a join, exactly one row per row of the previous table, through a primary key or unique NOT NULL index
refall rows matching one value of a non-unique index (or a prefix of a unique one)
ref_or_nulllike ref, plus the IS NULL rows
index_mergeseveral indexes searched and their results combined
rangea range of one index: <, >, BETWEEN, IN (…), LIKE 'abc%'
indexthe whole index read from start to end — a full scan, but of the smaller structure
ALLthe whole table read — a full table scan

ALL on a big table is the usual culprit. index is better only because the index is smaller than the table; it is still reading everything. Seeing ALL on the inner table of a join is the worst case: the whole table read once per outer row. MySQL 8.0.18 and later replace that with a hash join, shown as Using join buffer (hash join) — better, but still a sign an index on the join column is missing.

Extra: what to look for

Good signs:

  • Using index — a covering index: the step is answered from the index alone, never touching the rows.
  • Using index condition — index condition pushdown: part of the WHERE is tested on index entries before rows are fetched.
  • Using index for skip scan, Backward index scan — the index is used in a less obvious way.

Warning signs:

  • Using filesort — the rows are sorted for ORDER BY/GROUP BY because no index delivers that order. Not a file necessarily; it is any sort the server performs. Cheap on fifty rows, expensive on five million.
  • Using temporary — an internal temporary table is built, typically for a GROUP BY or DISTINCT on columns from different tables, or a UNION.
  • Using where with type = ALL — every row is read and most are thrown away.
  • Using join buffer — a join without a usable index on the inner table.

Neutral: Using where on its own just means a condition is checked after the access method.

A slow query, diagnosed

EXPLAIN SELECT * FROM orders WHERE YEAR(placed_on) = 2024 ORDER BY amount;
tabletypepossible_keyskeyrowsfilteredExtra
ordersALLNULLNULL498211100.00Using where; Using filesort

Read it left to right: full table scan (ALL), no candidate index (possible_keys NULL — even though placed_on is indexed, because the column is wrapped in a function), half a million rows examined, and then a sort. The fix is a predicate the index can use — placed_on >= '2024-01-01' AND placed_on < '2025-01-01' — after which the plan shows type: range, key: idx_orders_placed_on and far fewer rows. The next lesson is about exactly that rewrite.

EXPLAIN ANALYZE: the real numbers

EXPLAIN shows estimates. EXPLAIN ANALYZE (MySQL 8.0.18+) runs the query and prints the plan as a tree, each node with the estimate and what happened:

-> Sort: o.amount DESC  (actual time=4.21..4.30 rows=1083 loops=1)
    -> Nested loop inner join  (cost=520.4 rows=1080) (actual time=0.09..3.62 rows=1083 loops=1)
        -> Covering index lookup on c using idx_city (city='Pune')  (cost=12.3 rows=120) (actual time=0.05..0.11 rows=118 loops=1)
        -> Index lookup on o using idx_orders_customer (customer_id=c.id)  (cost=3.1 rows=9) (actual time=0.02..0.03 rows=9.2 loops=118)

Read it from the most indented line outwards. actual time=first..last is milliseconds to the first and last row, rows is per loop, and loops is how many times the node ran — rows=9.2 loops=118 is about 1,083 rows in total. The biggest gap between estimated and actual rows is where the optimizer was misled, usually by stale statistics (ANALYZE TABLE orders; refreshes them). Since EXPLAIN ANALYZE really executes the statement, never point it at a slow DELETE on production. EXPLAIN FORMAT=TREE gives the same tree without running it, and FORMAT=JSON adds the cost model's figures.

PostgreSQL's equivalents are EXPLAIN and EXPLAIN (ANALYZE, BUFFERS), with node names such as Seq Scan, Index Scan, Index Only Scan and Bitmap Heap Scan in place of MySQL's type column.

Practise

Both exercises work on plan rows stored as data — the columns of MySQL's EXPLAIN in a table. The first lists the full table scans, biggest first. The second ranks each step's access type on the ladder and reports each query's weakest step and whether it sorts.

Common questions

What does type ALL mean in MySQL EXPLAIN?

type ALL is a full table scan: the server reads every row of that table and tests the WHERE condition on each. On a small table it is fine; on a large one, or as the inner table of a join, it is usually the cause of a slow query. Look for a missing index or a predicate that cannot use one.

What is the difference between EXPLAIN and EXPLAIN ANALYZE?

EXPLAIN only plans the query and shows the optimizer's estimates without running it. EXPLAIN ANALYZE, added in MySQL 8.0.18, actually runs the query and prints the plan as a tree with real timings, actual row counts and loop counts beside the estimates, so you can see where the estimate was wrong. Do not run it on a slow data-changing statement in production.

What does Using filesort mean?

Using filesort means MySQL must sort the rows itself for ORDER BY or GROUP BY because no index delivers them in the needed order. Despite the name it happens in memory when the data fits the sort buffer. It is cheap for a few rows and costly for many; an index whose prefix matches the filter and then the sort columns removes it.

What does Using index mean in the Extra column?

Using index means the query is answered from the index alone, a covering index, without reading the table's rows. It is one of the best things to see in a plan. Do not confuse it with Using index condition, which means index condition pushdown is filtering entries inside the index before rows are fetched.

Why does MySQL not use my index?

The optimizer estimated a different plan was cheaper, or could not use the index at all. Common causes are a function or type conversion on the column, a leading wildcard in LIKE, a filter missing the composite index's first column, low selectivity so a scan is cheaper, or stale statistics, which ANALYZE TABLE refreshes.

Mark as readSign in to keep your place.

How to Read MySQL EXPLAIN: type, key, rows and Extra

MySQL EXPLAIN shows how a query runs: the access type from const to ALL, the chosen index, estimated rows, Extra notes like Using filesort, and EXPLAIN ANALYZE.

  • Course: SQL study plan
  • Module: Indexes and performance
  • Kind: Lesson
  • Reading time: 16 min
  • Runtime: MySQL 8 syntax on SQLite 3.49

How do you read MySQL EXPLAIN output?

Read MySQL EXPLAIN one row per table access, in join order. The type column says how rows are found, from const and eq_ref (one row by key) through ref and range down to index and ALL (full scans). The key column is the index chosen, rows is the estimated number examined, and Extra flags extra work such as Using filesort or Using temporary.

Lesson

An index you created is a hope; a query plan is the server telling you what it will actually do. Every performance conversation — in a code review, an incident or an interview — ends with "what does EXPLAIN say?". This lesson teaches MySQL 8's plan output column by column, the access-type ladder you should know by heart, the Extra notes that signal trouble, and EXPLAIN ANALYZE, which runs the query and shows the real numbers.

The exercise engine here is SQLite, whose EXPLAIN QUERY PLAN looks nothing like MySQL's, so this lesson is reading: the plans below are MySQL's, and the exercises analyse plan rows stored in a table.

Asking for a plan

Put EXPLAIN in front of a SELECT (or an UPDATE, DELETE, INSERT … SELECT). The query is planned, not run.

EXPLAIN
SELECT o.id, o.amount
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE c.city = 'Pune'
ORDER BY o.amount DESC;
idselect_typetabletypepossible_keyskeykey_lenrefrowsfilteredExtra
1SIMPLEcrefPRIMARY,idx_cityidx_city243const120100.00Using index; Using temporary; Using filesort
1SIMPLEorefidx_orders_customeridx_orders_customer4shop.c.id9100.00NULL

One row per table access, listed in the join order the optimizer chose: here it starts from the Pune customers and, for each, looks up their orders.

The columns

  • id and select_type — which SELECT the row belongs to: SIMPLE for a query without subqueries or unions, PRIMARY for the outer one, SUBQUERY, DERIVED (a derived table in FROM), UNION.
  • table — the table or alias accessed in this step.
  • type — how rows are found. The most important column; see the ladder below.
  • possible_keys — indexes that could apply. NULL here means no index matches the predicates at all.
  • key — the index actually chosen, or NULL for none.
  • key_len — how many bytes of the index key are used: on a composite index, a short key_len tells you only the first column was used to seek.
  • ref — what the key is compared with: const for a literal, db.table.column for a join column.
  • rows — the estimated number of rows examined in this step, per lookup. For a nested-loop join, multiply the steps' rows (and filtered) to estimate the total work.
  • filtered — the estimated percentage of those rows that survive the remaining conditions.
  • Extra — everything else, and often the most revealing part.

The access-type ladder

From best to worst — memorise the order:

typeMeaning
system / constat most one matching row, found by a primary key or unique index compared with a constant; read once
eq_refin a join, exactly one row per row of the previous table, through a primary key or unique NOT NULL index
refall rows matching one value of a non-unique index (or a prefix of a unique one)
ref_or_nulllike ref, plus the IS NULL rows
index_mergeseveral indexes searched and their results combined
rangea range of one index: <, >, BETWEEN, IN (…), LIKE 'abc%'
indexthe whole index read from start to end — a full scan, but of the smaller structure
ALLthe whole table read — a full table scan

ALL on a big table is the usual culprit. index is better only because the index is smaller than the table; it is still reading everything. Seeing ALL on the inner table of a join is the worst case: the whole table read once per outer row. MySQL 8.0.18 and later replace that with a hash join, shown as Using join buffer (hash join) — better, but still a sign an index on the join column is missing.

Extra: what to look for

Good signs:

  • Using index — a covering index: the step is answered from the index alone, never touching the rows.
  • Using index condition — index condition pushdown: part of the WHERE is tested on index entries before rows are fetched.
  • Using index for skip scan, Backward index scan — the index is used in a less obvious way.

Warning signs:

  • Using filesort — the rows are sorted for ORDER BY/GROUP BY because no index delivers that order. Not a file necessarily; it is any sort the server performs. Cheap on fifty rows, expensive on five million.
  • Using temporary — an internal temporary table is built, typically for a GROUP BY or DISTINCT on columns from different tables, or a UNION.
  • Using where with type = ALL — every row is read and most are thrown away.
  • Using join buffer — a join without a usable index on the inner table.

Neutral: Using where on its own just means a condition is checked after the access method.

A slow query, diagnosed

EXPLAIN SELECT * FROM orders WHERE YEAR(placed_on) = 2024 ORDER BY amount;
tabletypepossible_keyskeyrowsfilteredExtra
ordersALLNULLNULL498211100.00Using where; Using filesort

Read it left to right: full table scan (ALL), no candidate index (possible_keys NULL — even though placed_on is indexed, because the column is wrapped in a function), half a million rows examined, and then a sort. The fix is a predicate the index can use — placed_on >= '2024-01-01' AND placed_on < '2025-01-01' — after which the plan shows type: range, key: idx_orders_placed_on and far fewer rows. The next lesson is about exactly that rewrite.

EXPLAIN ANALYZE: the real numbers

EXPLAIN shows estimates. EXPLAIN ANALYZE (MySQL 8.0.18+) runs the query and prints the plan as a tree, each node with the estimate and what happened:

-> Sort: o.amount DESC  (actual time=4.21..4.30 rows=1083 loops=1)
    -> Nested loop inner join  (cost=520.4 rows=1080) (actual time=0.09..3.62 rows=1083 loops=1)
        -> Covering index lookup on c using idx_city (city='Pune')  (cost=12.3 rows=120) (actual time=0.05..0.11 rows=118 loops=1)
        -> Index lookup on o using idx_orders_customer (customer_id=c.id)  (cost=3.1 rows=9) (actual time=0.02..0.03 rows=9.2 loops=118)

Read it from the most indented line outwards. actual time=first..last is milliseconds to the first and last row, rows is per loop, and loops is how many times the node ran — rows=9.2 loops=118 is about 1,083 rows in total. The biggest gap between estimated and actual rows is where the optimizer was misled, usually by stale statistics (ANALYZE TABLE orders; refreshes them). Since EXPLAIN ANALYZE really executes the statement, never point it at a slow DELETE on production. EXPLAIN FORMAT=TREE gives the same tree without running it, and FORMAT=JSON adds the cost model's figures.

PostgreSQL's equivalents are EXPLAIN and EXPLAIN (ANALYZE, BUFFERS), with node names such as Seq Scan, Index Scan, Index Only Scan and Bitmap Heap Scan in place of MySQL's type column.

Practise

Both exercises work on plan rows stored as data — the columns of MySQL's EXPLAIN in a table. The first lists the full table scans, biggest first. The second ranks each step's access type on the ladder and reports each query's weakest step and whether it sorts.

Common questions

What does type ALL mean in MySQL EXPLAIN?

type ALL is a full table scan: the server reads every row of that table and tests the WHERE condition on each. On a small table it is fine; on a large one, or as the inner table of a join, it is usually the cause of a slow query. Look for a missing index or a predicate that cannot use one.

What is the difference between EXPLAIN and EXPLAIN ANALYZE?

EXPLAIN only plans the query and shows the optimizer's estimates without running it. EXPLAIN ANALYZE, added in MySQL 8.0.18, actually runs the query and prints the plan as a tree with real timings, actual row counts and loop counts beside the estimates, so you can see where the estimate was wrong. Do not run it on a slow data-changing statement in production.

What does Using filesort mean?

Using filesort means MySQL must sort the rows itself for ORDER BY or GROUP BY because no index delivers them in the needed order. Despite the name it happens in memory when the data fits the sort buffer. It is cheap for a few rows and costly for many; an index whose prefix matches the filter and then the sort columns removes it.

What does Using index mean in the Extra column?

Using index means the query is answered from the index alone, a covering index, without reading the table's rows. It is one of the best things to see in a plan. Do not confuse it with Using index condition, which means index condition pushdown is filtering entries inside the index before rows are fetched.

Why does MySQL not use my index?

The optimizer estimated a different plan was cheaper, or could not use the index at all. Common causes are a function or type conversion on the column, a leading wildcard in LIKE, a filter missing the composite index's first column, low selectivity so a scan is cheaper, or stale statistics, which ANALYZE TABLE refreshes.

Exercises

Find the full table scans

plan_rows stores the EXPLAIN output of several slow queries, one row per table access. List every full table scan (type = 'ALL'), with a hint for the fix: 'no candidate index' when possible_keys is NULL, otherwise 'index not chosen'.

plan_rows:

query_idsteptable_nametypepossible_keyskey_namerows_examinedextra
11customersrefidx_cityidx_city120Using index
12ordersALLNULLNULL498211Using where; Using join buffer (hash join)
21ordersrangeidx_placed_onidx_placed_on3100Using index condition; Using filesort
31productsALLidx_categoryNULL2400Using where
32reviewsrefidx_productidx_product14NULL
41usersconstPRIMARYPRIMARY1NULL
51eventsindexidx_tenant_timeidx_tenant_time75000Using index; Using filesort

Output columns: query_id, table_name, rows_examined, fix_hint, ordered by rows_examined descending, then query_id, then table_name.

query_id | table_name | rows_examined | fix_hint
1 | orders | 498211 | no candidate index
3 | products | 2400 | index not chosen

Each query's weakest step

Rank every step of plan_rows on MySQL's access-type ladder — system 1, const 2, eq_ref 3, ref 4, ref_or_null 5, index_merge 6, range 7, index 8, ALL 9 — and report, for each query, the type of its worst step (highest rank) and whether any of its steps sorts (extra contains Using filesort). The same plan_rows table as the previous exercise; extra can be NULL.

Output columns: query_id, worst_type, needs_sort ('yes' or 'no'), ordered by query_id.

query_id | worst_type | needs_sort
1 | ALL | no
2 | range | yes
3 | ALL | no
4 | const | no
5 | index | yes

In this module: Indexes and performance

← Composite indexes and the leftmost prefix · Sargable queries — predicates an index can use →