How the EXPLAIN Statement Works in MySQL

In MySQL we can use the EXPLAIN statement to get information about SQL queries that we run against the database. We can also get information about tables and views.

EXPLAIN is a synonym for DESCRIBE (and its short form DESC). Even though these statements all work exactly the same, there’s a common convention amongst MySQL developers to use DESCRIBE for certain tasks and EXPLAIN for others. Typically, DESCRIBE is often used to get information about tables and views, whereas EXPLAIN is used to get information about queries, such as query execution plans.

Example

To start off simple, let’s use EXPLAIN to get information about a view in the sakila sample database:

EXPLAIN actor_info;

Result:

+------------+-------------------+------+-----+---------+-------+
| Field      | Type              | Null | Key | Default | Extra |
+------------+-------------------+------+-----+---------+-------+
| actor_id   | smallint unsigned | NO   |     | 0       |       |
| first_name | varchar(45)       | NO   |     | NULL    |       |
| last_name  | varchar(45)       | NO   |     | NULL    |       |
| film_info  | text              | YES  |     | NULL    |       |
+------------+-------------------+------+-----+---------+-------+

This returns information about the columns in the view. We can see the names of each column in the Field column, their respective data types, whether or not they’re nullable, and more. The Extra column can tell us things like if the column is a generated column, whether it’s an invisible column, and more.

Get the Query Execution Plan

The EXPLAIN statement is often used to get information about queries, such as a query execution plan that shows how MySQL executes the SQL statement behind the scenes.

We can do this for SELECT, DELETE, INSERT, REPLACE, TABLE, and UPDATE statements.

Here’s what happens when we select all columns and rows from the above view:

EXPLAIN SELECT * FROM actor_info;

Result:

+----+--------------------+------------+------------+--------+-----------------------------------+---------+---------+----------------------------------------+------+----------+----------------+
| id | select_type        | table      | partitions | type   | possible_keys                     | key     | key_len | ref                                    | rows | filtered | Extra          |
+----+--------------------+------------+------------+--------+-----------------------------------+---------+---------+----------------------------------------+------+----------+----------------+
|  1 | PRIMARY            | <derived2> | NULL       | ALL    | NULL                              | NULL    | NULL    | NULL                                   | 5461 |   100.00 | NULL           |
|  2 | DERIVED            | a          | NULL       | ALL    | NULL                              | NULL    | NULL    | NULL                                   |  200 |   100.00 | Using filesort |
|  2 | DERIVED            | fa         | NULL       | ref    | PRIMARY                           | PRIMARY | 2       | sakila.a.actor_id                      |   27 |   100.00 | Using index    |
|  2 | DERIVED            | fc         | NULL       | ref    | PRIMARY                           | PRIMARY | 2       | sakila.fa.film_id                      |    1 |   100.00 | Using index    |
|  2 | DERIVED            | c          | NULL       | eq_ref | PRIMARY                           | PRIMARY | 1       | sakila.fc.category_id                  |    1 |   100.00 | NULL           |
|  3 | DEPENDENT SUBQUERY | fa         | NULL       | ref    | PRIMARY,idx_fk_film_id            | PRIMARY | 2       | sakila.a.actor_id                      |   27 |   100.00 | Using index    |
|  3 | DEPENDENT SUBQUERY | f          | NULL       | eq_ref | PRIMARY                           | PRIMARY | 2       | sakila.fa.film_id                      |    1 |   100.00 | NULL           |
|  3 | DEPENDENT SUBQUERY | fc         | NULL       | eq_ref | PRIMARY,fk_film_category_category | PRIMARY | 3       | sakila.fa.film_id,sakila.c.category_id |    1 |   100.00 | Using index    |
+----+--------------------+------------+------------+--------+-----------------------------------+---------+---------+----------------------------------------+------+----------+----------------+
8 rows in set, 3 warnings (0.00 sec)

We can see that this view selects data from multiple tables, we can see whether or not it uses an index in the process, and much more.

We can also see that there are three warnings in this case. Warnings provide additional execution plan information.

Let’s check the warnings:

SHOW WARNINGS;

Result:

+-------+------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Level | Code | Message                                                                                                                                                                                                                                              |
+-------+------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Note  | 1276 | Field or reference 'sakila.c.category_id' of SELECT #3 was resolved in SELECT #2                                                                                                                                                                     |
| Note  | 1276 | Field or reference 'sakila.a.actor_id' of SELECT #3 was resolved in SELECT #2                                                                                                                                                                        |
| Note  | 1003 | /* select#1 */ select `sakila`.`actor_info`.`actor_id` AS `actor_id`,`sakila`.`actor_info`.`first_name` AS `first_name`,`sakila`.`actor_info`.`last_name` AS `last_name`,`sakila`.`actor_info`.`film_info` AS `film_info` from `sakila`.`actor_info` |
+-------+------+------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

We can get the same result by copying and pasting the query from the view:

EXPLAIN
SELECT 
    `a`.`actor_id` AS `actor_id`,
    `a`.`first_name` AS `first_name`,
    `a`.`last_name` AS `last_name`,
    GROUP_CONCAT(DISTINCT CONCAT(`c`.`name`,': ',
    (SELECT group_concat(`f`.`title` ORDER BY `f`.`title` ASC separator ', ') 
FROM ((`film` `f` JOIN `film_category` `fc` ON((`f`.`film_id` = `fc`.`film_id`))) JOIN `film_actor` `fa` ON((`f`.`film_id` = `fa`.`film_id`))) 
WHERE ((`fc`.`category_id` = `c`.`category_id`) AND (`fa`.`actor_id` = `a`.`actor_id`)))) 
ORDER BY `c`.`name` ASC separator '; ') AS `film_info` FROM (((`actor` `a` LEFT JOIN `film_actor` `fa` ON((`a`.`actor_id` = `fa`.`actor_id`))) LEFT JOIN `film_category` `fc` ON((`fa`.`film_id` = `fc`.`film_id`))) LEFT JOIN `category` `c` ON((`fc`.`category_id` = `c`.`category_id`))) 
GROUP BY `a`.`actor_id`,`a`.`first_name`,`a`.`last_name`;

Result:

+----+--------------------+-------+------------+--------+-----------------------------------+---------+---------+----------------------------------------+------+----------+----------------+
| id | select_type        | table | partitions | type   | possible_keys                     | key     | key_len | ref                                    | rows | filtered | Extra          |
+----+--------------------+-------+------------+--------+-----------------------------------+---------+---------+----------------------------------------+------+----------+----------------+
|  1 | PRIMARY            | a     | NULL       | ALL    | NULL                              | NULL    | NULL    | NULL                                   |  200 |   100.00 | Using filesort |
|  1 | PRIMARY            | fa    | NULL       | ref    | PRIMARY                           | PRIMARY | 2       | sakila.a.actor_id                      |   27 |   100.00 | Using index    |
|  1 | PRIMARY            | fc    | NULL       | ref    | PRIMARY                           | PRIMARY | 2       | sakila.fa.film_id                      |    1 |   100.00 | Using index    |
|  1 | PRIMARY            | c     | NULL       | eq_ref | PRIMARY                           | PRIMARY | 1       | sakila.fc.category_id                  |    1 |   100.00 | NULL           |
|  2 | DEPENDENT SUBQUERY | fa    | NULL       | ref    | PRIMARY,idx_fk_film_id            | PRIMARY | 2       | sakila.a.actor_id                      |   27 |   100.00 | Using index    |
|  2 | DEPENDENT SUBQUERY | f     | NULL       | eq_ref | PRIMARY                           | PRIMARY | 2       | sakila.fa.film_id                      |    1 |   100.00 | NULL           |
|  2 | DEPENDENT SUBQUERY | fc    | NULL       | eq_ref | PRIMARY,fk_film_category_category | PRIMARY | 3       | sakila.fa.film_id,sakila.c.category_id |    1 |   100.00 | Using index    |
+----+--------------------+-------+------------+--------+-----------------------------------+---------+---------+----------------------------------------+------+----------+----------------+
7 rows in set, 3 warnings (0.00 sec)

Specify the Format

We can use the FORMAT argument to specify the output format. Supported formats are TRADITIONAL, JSON, and TREE.

Here’s an example of specifying JSON:

EXPLAIN FORMAT=JSON SELECT * FROM actor;

Result:

{
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "20.25"
    },
    "table": {
      "table_name": "actor",
      "access_type": "ALL",
      "rows_examined_per_scan": 200,
      "rows_produced_per_join": 200,
      "filtered": "100.00",
      "cost_info": {
        "read_cost": "0.25",
        "eval_cost": "20.00",
        "prefix_cost": "20.25",
        "data_read_per_join": "73K"
      },
      "used_columns": [
        "actor_id",
        "first_name",
        "last_name",
        "last_update"
      ]
    }
  }
}

In this case the query selected all rows from a table called actor.

The default format is TRADITIONAL, but this will depend on your explain_format system variable if present (this variable was introduced in MySQL 8.0.32). If this variable is set to a value other than TRADITIONAL, then the default format will be whatever that value is (assuming it’s a valid value).

We can check the value of the explain_format system variable like this:

SELECT @@explain_format;

Result:

+------------------+
| @@explain_format |
+------------------+
| TRADITIONAL      |
+------------------+

In my case it’s set to the default value of TRADITIONAL.

Using EXPLAIN ANALYZE

From MySQL 8.0.18 we can use EXPLAIN ANALYZE to get EXPLAIN output along with timing and additional, iterator-based, information about how the optimiser’s expectations matched the actual execution.

We can use EXPLAIN ANALYZE on SELECT and TABLE statements, as well as with multi-table UPDATE and DELETE statements.

Example:

EXPLAIN ANALYZE
SELECT 
    `albums`.`AlbumName` AS `AlbumName`,
    `artists`.`ArtistName` AS `ArtistName`,
    `albums`.`ReleaseDate` AS `ReleaseDate` 
FROM ((`albums` 
    JOIN `artists` ON((`albums`.`ArtistId` = `artists`.`ArtistId`))) 
    JOIN `genres` ON((`albums`.`GenreId` = `genres`.`GenreId`)));

Result:

-> Nested loop inner join  (cost=16.2 rows=20) (actual time=0.113..0.209 rows=20 loops=1)
    -> Nested loop inner join  (cost=9.25 rows=20) (actual time=0.102..0.156 rows=20 loops=1)
        -> Filter: (albums.GenreId is not null)  (cost=2.25 rows=20) (actual time=0.0773..0.0982 rows=20 loops=1)
            -> Table scan on albums  (cost=2.25 rows=20) (actual time=0.0757..0.0932 rows=20 loops=1)
        -> Single-row covering index lookup on genres using PRIMARY (GenreId=albums.GenreId)  (cost=0.255 rows=1) (actual time=0.00233..0.00241 rows=1 loops=20)
    -> Single-row index lookup on artists using PRIMARY (ArtistId=albums.ArtistId)  (cost=0.255 rows=1) (actual time=0.00211..0.00219 rows=1 loops=20)

The EXPLAIN ANALYZE statement only supports the TREE format. Each node represents an iterator. Each iterator outputs the estimated execution cost, the estimated number of returned rows, the time to return first row, time spent (in milliseconds) executing this iterator (including child iterators, but not parent iterators), and the number of rows returned by the iterator.

As of MySQL 8.0.32, if the explain_format system variable (which was introduced in MySQL 8.0.32) is set to TRADITIONAL, DEFAULT or TREE, then TREE is used. If the value is JSON, then an error is returned, unless FORMAT=TREE is specified as part of the statement.

I didn’t get an error in the above example because my explain_format system variable is set to TRADITIONAL. Therefore, it used TREE. But if my explain_format variable was set to JSON, then I would’ve received an error.

To demonstrate, let’s set explain_format to JSON and find out:

SET @@explain_format=JSON;

And now run EXPLAIN ANALYZE again:

EXPLAIN ANALYZE
SELECT 
    `albums`.`AlbumName` AS `AlbumName`,
    `artists`.`ArtistName` AS `ArtistName`,
    `albums`.`ReleaseDate` AS `ReleaseDate` 
FROM ((`albums` 
    JOIN `artists` ON((`albums`.`ArtistId` = `artists`.`ArtistId`))) 
    JOIN `genres` ON((`albums`.`GenreId` = `genres`.`GenreId`)));

Result:

ERROR 1235 (42000): This version of MySQL doesn't yet support 'EXPLAIN ANALYZE with JSON format'

So it’s probably a good idea to explicitly include FORMAT=TREE in our EXPLAIN ANALYZE statement if we want to avoid such errors.

EXPLAIN for a Specific Connection

We can use EXPLAIN against a specific connection to get the execution plan for the statement executing in that connection.

To demonstrate this, let’s select the SLEEP() function and specify 20 seconds:

SELECT SLEEP(20);

While that’s running, get a list of connections:

SHOW PROCESSLIST;

Result:

+----+-----------------+-----------------+--------+---------+---------+------------------------+------------------+
| Id | User            | Host            | db     | Command | Time    | State                  | Info             |
+----+-----------------+-----------------+--------+---------+---------+------------------------+------------------+
|  5 | event_scheduler | localhost       | NULL   | Daemon  | 1150848 | Waiting on empty queue | NULL             |
| 94 | root            | localhost       | music  | Query   |       0 | init                   | SHOW PROCESSLIST |
| 97 | root            | localhost:52349 | sakila | Sleep   |     513 |                        | NULL             |
| 98 | root            | localhost:52353 | sakila | Query   |       2 | User sleep             | SELECT SLEEP(20) |
+----+-----------------+-----------------+--------+---------+---------+------------------------+------------------+

We can see that connection 98 is the one using SELECT SLEEP(20).

While that’s still running, let’s now use EXPLAIN for that connection:

EXPLAIN FOR CONNECTION 98;

Result:

+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows | filtered | Extra          |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+
|  1 | SIMPLE      | NULL  | NULL       | NULL | NULL          | NULL | NULL    | NULL | NULL |     NULL | No tables used |
+----+-------------+-------+------------+------+---------------+------+---------+------+------+----------+----------------+

It returned the information based on the connection ID that we passed.

Note that we can’t use FOR CONNECTION when using EXPLAIN ANALYZE.

EXPLAIN for a Specific Database

From MySQL 8.3, the EXPLAIN statement supports a FOR SCHEMA clause and its synonym FOR DATABASE. This allows us to run EXPLAIN as if it was executed in the specified database.

Here’s a simple example of using FOR SCHEMA:

USE PetHotel;
EXPLAIN FORMAT=TREE FOR SCHEMA sakila 
SELECT * FROM actor;

Result:

-> Table scan on actor  (cost=21 rows=200)

In this example I changed to the PetHotel database (using USE PetHotel), then I ran EXPLAIN with FOR SCHEMA agains the sakila database.

Here’s what happens if I run it without FOR SCHEMA:

USE PetHotel;
EXPLAIN FORMAT=TREE
SELECT * FROM actor;

Result:

ERROR 1146 (42S02): Table 'pethotel.actor' doesn't exist

The PetHotel database doesn’t actually have a table called actor, so we get an error.

If the specified database doesn’t exist we get an error:

USE PetHotel;
EXPLAIN FORMAT=TREE FOR SCHEMA oops 
SELECT * FROM actor;

Result:

ERROR 1049 (42000): Unknown database 'oops'

Also, we can’t use FOR SCHEMA and FOR CONNECTION in the same statement.

Privileges

EXPLAIN requires the same privileges required to execute the explained statement. It also requires the SHOW VIEW privilege for any explained view.

Also EXPLAIN … FOR CONNECTION requires the PROCESS privilege if the specified connection belongs to a different user.

More Information

See the MySQL documentation for more information about the EXPLAIN statement.