MySQLQuery Optimization (EXPLAIN)

MySQL EXPLAIN: Query Execution Plans

EXPLAIN is the single most important tool for query optimization in MySQL. It shows how the MySQL optimizer plans to execute a query — which indexes it will use, how many rows it expects to examine, and what operations it will perform. Understanding EXPLAIN output lets you identify and fix slow queries before they become production problems.

Basic EXPLAIN Syntax

SQL
-- Prepend EXPLAIN to any SELECT, INSERT, UPDATE, DELETE, or REPLACE
EXPLAIN SELECT u.name, COUNT(o.id) AS order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2024-01-01'
GROUP BY u.id;
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-----------------------------+
| id | select_type | table | partitions | type | possible_keys | key  | key_len | ref  | rows   | filtered | Extra                       |
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-----------------------------+
|  1 | SIMPLE      | u     | NULL       | ALL  | NULL          | NULL | NULL    | NULL | 150000 |    33.33 | Using where; Using temporary|
|  1 | SIMPLE      | o     | NULL       | ref  | user_id       | user_id | 4    | u.id |      3 |   100.00 | NULL                        |
+----+-------------+-------+------------+------+---------------+------+---------+------+--------+----------+-----------------------------+
All Output Columns Explained

Column

Description

id

Query block number. Rows with the same id belong to the same SELECT. Higher id = subquery executed first.

select_type

The type of SELECT: SIMPLE, PRIMARY, SUBQUERY, DERIVED, UNION, UNION RESULT, DEPENDENT SUBQUERY, etc.

table

The table being accessed. Can be a real table name, alias, or a derived table label like <derived2>.

partitions

Which partitions will be scanned. NULL if the table is not partitioned.

type

The access type — the most important column. See the full list below.

possible_keys

All indexes MySQL considered for this table access. NULL means no usable index exists.

key

The index MySQL actually chose to use. NULL means no index is used — potential full scan.

key_len

How many bytes of the chosen index MySQL uses. Useful for composite indexes — tells you how many columns are used.

ref

Which columns or constants are compared to the key. Shows what is being looked up in the index.

rows

Estimated number of rows MySQL will examine. Lower is better. Multiply across all tables for total work estimate.

filtered

Estimated percentage of rows that pass the WHERE condition after the index access. rows * filtered/100 = actual expected rows.

Extra

Additional information about the execution — very important for diagnosing performance issues. See the Extra section.

Access Types — The type Column

The type column shows how MySQL accesses each table. Listed from best to worst:

Type

When It Occurs

Performance

system

Table has exactly one row (system table or const with 1 row).

Instant

const

At most one matching row — PRIMARY KEY or UNIQUE index lookup with a constant value.

Excellent

eq_ref

Exactly one row returned per row from the previous table — UNIQUE index or PK join.

Excellent

ref

All rows matching an index value — non-unique index or leftmost prefix of a unique index.

Good

fulltext

A FULLTEXT index is used.

Good for text search

ref_or_null

Like ref but also searches for NULL values in the indexed column.

Good

index_merge

MySQL uses multiple indexes and merges the results (via union or intersection).

Moderate

range

Index range scan — used for BETWEEN, IN(), >, <, >=, <=, LIKE prefix.

Moderate

index

Full index scan — reads every leaf node of the index without accessing the table. Better than ALL, but still slow on large indexes.

Slow

ALL

Full table scan — every row is read. Avoid on tables with more than a few thousand rows.

Worst

Tip
If you see type: ALL on a table with many rows, that query needs an index. Adding or fixing an index to achieve at least type: ref or type: range is usually the highest-impact optimization you can make.
The Extra Column — Full Dictionary

Extra Value

Meaning

Action Needed

Using index

Covering index — query answered entirely from the index, no table row access needed. Best case.

None — this is ideal

Using where

WHERE clause filters rows after the storage engine returns them. Normal for non-index conditions.

Check if a better index can reduce rows read first

Using index condition

Index Condition Pushdown (ICP) — conditions are applied at the storage engine level before returning rows to SQL layer.

Usually fine — ICP is an optimization

Using filesort

MySQL must sort results in memory or on disk. Appears when ORDER BY columns are not covered by an index.

Add an index matching the ORDER BY columns

Using temporary

MySQL creates an internal temporary table — common with GROUP BY on non-indexed columns or complex DISTINCT queries.

Investigate — can be expensive for large datasets

Using join buffer (Block Nested Loop)

No index on the inner table join column. MySQL buffers rows in memory for a nested loop join.

Add an index on the join column

Impossible WHERE

WHERE condition is always false — no rows can ever match. Query returns empty instantly.

Fix the query logic — often a type mismatch or impossible range

Select tables optimized away

Query resolved entirely via index (e.g., MIN/MAX with a single-column index, COUNT(*) from information_schema).

Ideal — nothing to do

Using MRR

Multi-Range Read optimization is active — randomizing disk read order to reduce seeks.

Fine — this is an optimizer optimization

No tables used

Query has no FROM clause, or uses only constants.

Normal for SELECT 1, SELECT NOW(), etc.

EXPLAIN FORMAT=JSON

The JSON format provides more detail including cost estimates, which are not available in the tabular format:

SQL
EXPLAIN FORMAT=JSON
SELECT * FROM orders WHERE customer_id = 42G
{
  "query_block": {
    "select_id": 1,
    "cost_info": {
      "query_cost": "1.05"
    },
    "table": {
      "table_name": "orders",
      "access_type": "ref",
      "possible_keys": ["idx_customer_id"],
      "key": "idx_customer_id",
      "used_key_parts": ["customer_id"],
      "key_length": "4",
      "ref": ["const"],
      "rows_examined_per_scan": 3,
      "rows_produced_per_join": 3,
      "filtered": "100.00",
      "cost_info": {
        "read_cost": "0.75",
        "eval_cost": "0.30",
        "prefix_cost": "1.05"
      }
    }
  }
}

Key JSON-only fields:

  • query_cost: total optimizer cost estimate for the entire query
  • read_cost: estimated cost to read rows from storage
  • eval_cost: estimated cost to evaluate row conditions
  • rows_examined_per_scan: how many rows the storage engine scans per outer table loop
EXPLAIN ANALYZE (MySQL 8.0+)

EXPLAIN ANALYZE actually executes the query and reports real row counts and timing alongside the optimizer's estimates. This reveals when optimizer estimates are wildly off from reality — a common cause of poor query plans.

SQL
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id)
FROM users u
JOIN orders o ON o.user_id = u.id
GROUP BY u.idG
-> Table scan on <temporary>  (actual time=8.45..8.51 rows=1000 loops=1)
    -> Aggregate using temporary table  (actual time=8.43..8.43 rows=1000 loops=1)
        -> Nested loop inner join  (cost=4521.40 rows=3000) (actual time=0.10..6.20 rows=5000 loops=1)
            -> Table scan on u  (cost=1023.30 rows=10000) (actual time=0.05..1.80 rows=10000 loops=1)
            -> Index lookup on o using idx_user_id (user_id=u.id)
               (cost=0.25 rows=0) (actual time=0.00..0.00 rows=1 loops=10000)

The output shows:

  • cost=: the optimizer's estimated cost (estimate)
  • rows=: the optimizer's estimated row count (estimate)
  • actual time=x..y: real start..end time in milliseconds
  • actual rows=: the real number of rows produced

In the example above, the optimizer estimated 0 rows per index lookup but the actual was 1 — the statistics were stale. Running ANALYZE TABLE orders; would fix the estimates.

Warning
EXPLAIN ANALYZE executes the query. Use it on SELECT statements only, or wrap DML (UPDATE/DELETE) in a transaction and roll back to avoid unintended data changes.
Reading EXPLAIN — Before and After Optimization

SQL
-- Before: no index on status column
EXPLAIN SELECT * FROM orders WHERE status = 'pending'G
id: 1
select_type: SIMPLE
table: orders
type: ALL              <-- full table scan!
possible_keys: NULL    <-- no usable indexes
key: NULL              <-- no index used
rows: 2500000          <-- scanning 2.5M rows
filtered: 33.33
Extra: Using where

SQL
-- Fix: add a composite index for this common query pattern
CREATE INDEX idx_orders_status_date ON orders (status, created_at);

-- After: re-run EXPLAIN
EXPLAIN SELECT * FROM orders
WHERE status = 'pending' AND created_at > '2024-01-01'G
id: 1
select_type: SIMPLE
table: orders
type: range            <-- index range scan
key: idx_orders_status_date    <-- using the new index
key_len: 86            <-- using both columns (status + created_at)
rows: 15243            <-- examining only matching rows (vs 2.5M before)
filtered: 100.00
Extra: Using index condition
select_type Values

select_type

Meaning

SIMPLE

Simple SELECT with no subqueries or UNIONs.

PRIMARY

The outermost SELECT in a query with subqueries.

SUBQUERY

A subquery in the SELECT or WHERE clause.

DERIVED

A subquery in the FROM clause (derived table).

DEPENDENT SUBQUERY

A correlated subquery that references columns from the outer query.

UNION

The second or later SELECT in a UNION.

UNION RESULT

The result of a UNION — MySQL uses a temporary table to merge results.

MATERIALIZED

A subquery that is materialized (computed once and stored as a temp table).

Optimizer Hints

When the optimizer makes poor choices, guide it with hints:

SQL
-- Force use of a specific index
SELECT * FROM orders USE INDEX (idx_status)
WHERE status = 'pending';

-- Ignore a specific index (make optimizer choose differently)
SELECT * FROM orders IGNORE INDEX (idx_created_at)
WHERE status = 'pending';

-- MySQL 8.0 optimizer hints (preferred over USE INDEX)
SELECT /*+ INDEX(orders idx_status) */ *
FROM orders
WHERE status = 'pending';

-- Force a specific join order
SELECT /*+ JOIN_ORDER(u, o) */ u.name, o.total
FROM users u
JOIN orders o ON o.user_id = u.id;

-- Set max execution time to prevent runaway queries
SELECT /*+ MAX_EXECUTION_TIME(3000) */ *
FROM reports WHERE year = 2024;
Tip
Prefer optimizer hints written as comments over USE INDEX / FORCE INDEX syntax. They are more expressive, self-documenting, and do not affect portability to other MySQL-compatible databases.
Diagnosing Common EXPLAIN Problems
  1. type: ALL on a large table — add an index on the column(s) in the WHERE clause.

  2. Using filesort — add an index that covers the ORDER BY columns, or add them to the tail of a composite index.

  3. Using temporary — consider an index covering both the GROUP BY and WHERE columns; or investigate whether DISTINCT can be removed.

  4. Using join buffer — add an index on the join column of the inner table.

  5. rows estimate is much higher than actual — run ANALYZE TABLE to refresh statistics.

  6. possible_keys has good options but key is NULL — the optimizer chose a full scan because its statistics predict the index is not selective enough. Try FORCE INDEX or restructure the query.

  7. key_len is shorter than expected for a composite index — the WHERE clause is not using all prefix columns. Recheck column ordering in the composite index.

Reading key_len to Diagnose Composite Index Usage

The key_len column tells you how many bytes of a composite index are being used. This lets you verify whether all columns of the index are actually in play:

SQL
-- Composite index: (user_id INT, status VARCHAR(20), created_at DATETIME)
-- INT = 4 bytes, VARCHAR(20) utf8mb4 = up to 20*4 + 2 = 82 bytes, DATETIME = 5 bytes

-- Query 1: WHERE user_id = 42
EXPLAIN SELECT * FROM orders WHERE user_id = 42G
-- key_len: 4   <-- only first column used (user_id INT = 4 bytes)

-- Query 2: WHERE user_id = 42 AND status = 'paid'
EXPLAIN SELECT * FROM orders WHERE user_id = 42 AND status = 'paid'G
-- key_len: 86  <-- first two columns used (4 + 82 = 86 bytes)

-- Query 3: WHERE user_id = 42 AND status = 'paid' ORDER BY created_at
EXPLAIN SELECT * FROM orders
WHERE user_id = 42 AND status = 'paid'
ORDER BY created_atG
-- key_len: 86  <-- still 86 (ORDER BY uses the index structure but not reflected in key_len)
-- Extra: NULL  <-- no filesort! ORDER BY is handled by index
EXPLAIN for INSERT, UPDATE, DELETE

SQL
-- EXPLAIN works on DML statements too
EXPLAIN UPDATE orders
SET status = 'shipped'
WHERE user_id = 42 AND status = 'paid';

-- EXPLAIN INSERT ... SELECT
EXPLAIN INSERT INTO order_archive
SELECT * FROM orders WHERE created_at < '2023-01-01';

-- EXPLAIN DELETE
EXPLAIN DELETE FROM sessions WHERE expires_at < NOW();
Subquery Types in EXPLAIN

EXPLAIN breaks subqueries into separate rows. The select_type column identifies each query block:

SQL
-- Query with multiple SELECT types
EXPLAIN
SELECT u.name,
       (SELECT COUNT(*) FROM orders WHERE user_id = u.id) AS order_count
FROM users u
WHERE u.id IN (SELECT user_id FROM vip_customers)G

-- Expected output:
-- id=1, select_type=PRIMARY: the outer SELECT on users
-- id=2, select_type=DEPENDENT SUBQUERY: the correlated COUNT subquery (runs once per user)
-- id=3, select_type=SUBQUERY: the IN (SELECT) on vip_customers (runs once)

-- Rewritten to avoid DEPENDENT SUBQUERY (correlated)
EXPLAIN
SELECT u.name, COUNT(o.id) AS order_count
FROM users u
JOIN vip_customers v ON v.user_id = u.id
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.nameG
-- All rows now show SIMPLE or PRIMARY — no dependent subqueries
EXPLAIN and Partitioned Tables

SQL
-- Create a partitioned table
CREATE TABLE orders_partitioned (
  id         INT AUTO_INCREMENT,
  created_at DATE NOT NULL,
  total      DECIMAL(10,2),
  PRIMARY KEY (id, created_at)
) PARTITION BY RANGE (YEAR(created_at)) (
  PARTITION p2022 VALUES LESS THAN (2023),
  PARTITION p2023 VALUES LESS THAN (2024),
  PARTITION p2024 VALUES LESS THAN (2025),
  PARTITION future VALUES LESS THAN MAXVALUE
);

-- EXPLAIN shows which partitions are scanned
EXPLAIN SELECT * FROM orders_partitioned
WHERE created_at BETWEEN '2024-01-01' AND '2024-12-31'G
-- partitions: p2024   <-- only the 2024 partition is scanned (partition pruning)

-- Without partition pruning (all partitions scanned):
EXPLAIN SELECT * FROM orders_partitioned WHERE total > 100G
-- partitions: p2022,p2023,p2024,future
EXPLAIN with Derived Tables and CTEs

SQL
-- EXPLAIN shows derived tables as <derived2>, <derived3>, etc.
EXPLAIN
SELECT d.user_id, d.order_count, u.name
FROM (
  SELECT user_id, COUNT(*) AS order_count
  FROM orders
  GROUP BY user_id
  HAVING COUNT(*) > 5
) AS d
JOIN users u ON u.id = d.user_idG

-- Expected output includes a row for the derived table:
-- id=2, select_type=DERIVED, table=orders, type=ALL (subquery scans orders)
-- id=1, select_type=PRIMARY, table=<derived2>, type=ALL (result of subquery)
-- id=1, select_type=PRIMARY, table=u, type=eq_ref (join on PK)

-- In MySQL 8.0+, CTEs are materialized or merged:
EXPLAIN WITH top_users AS (
  SELECT user_id FROM orders GROUP BY user_id HAVING COUNT(*) > 10
)
SELECT u.name FROM users u WHERE u.id IN (SELECT user_id FROM top_users)G
-- select_type for CTE: MATERIALIZED or SUBQUERY
Cost Estimates in EXPLAIN FORMAT=JSON

The JSON format is the only way to see the optimizer's internal cost estimates. These numbers are unit-less but comparable:

SQL
EXPLAIN FORMAT=JSON SELECT u.name, COUNT(o.id)
FROM users u JOIN orders o ON o.user_id = u.id
GROUP BY u.idG
-- Look for:
-- "query_cost": total estimated cost
-- "read_cost": cost to read rows from storage
-- "eval_cost": cost to evaluate conditions
-- Higher cost = more work = potentially slower

-- Compare two query variants by their query_cost values
-- The optimizer always chooses the lower-cost plan
-- If you disagree with the choice, check if statistics are stale (ANALYZE TABLE)
Common EXPLAIN Misreadings

What you see

Common Misread

Correct Interpretation

type: index

Thinks this means an index is being used efficiently

Full index scan — reads every leaf node of the index. Only better than ALL if the index is much smaller than the table.

rows: 1

Assumes the query is always fast

Just an estimate. The actual row count could be very different — use EXPLAIN ANALYZE to verify.

Extra: NULL (empty)

Assumes nothing extra is happening

Empty Extra is normal and fine — it just means no special operations like filesort or covering index are noted.

possible_keys: many options

Assumes the optimizer uses the best one

More options in possible_keys is not always better. The optimizer picks based on cost — check the key column for what was actually chosen.

filtered: 100.00

Assumes this is always good

100% filtered just means no additional WHERE filtering after the index access — could mean all rows passed, which is fine for equality lookups.

EXPLAIN Workflow
  1. Run EXPLAIN on the slow query and check the type column for ALL or index on large tables.

  2. Multiply rows across all table rows to estimate total work: if table 1 returns 1000 rows and table 2 rows estimate is 500, MySQL does up to 500,000 row accesses.

  3. Check Extra for Using filesort or Using temporary — these indicate expensive sorting/grouping operations.

  4. Read key_len for composite indexes to confirm how many columns are actually in use.

  5. Verify possible_keys vs key — if a good index exists but is not chosen, ANALYZE TABLE and re-check.

  6. Add missing indexes, then re-run EXPLAIN to confirm the plan improved.

  7. On MySQL 8.0, use EXPLAIN ANALYZE to compare optimizer estimates against actual execution and catch stale statistics.

  8. Repeat after each change — one query improvement can reveal the next bottleneck.