Skip to content
Bible, Lee, Data
Engineering/Database

How to Read SQL Execution Plans with EXPLAIN

When a SQL query is slow, the first instinct is often to add an index.

That may solve the problem, but it may also miss the real cause. The query might already have a usable index that the optimizer decided not to use. The join order may be inefficient, the optimizer may have estimated the wrong number of rows, or the database may be sorting a large intermediate result.

Consider the following query:

SELECT
    o.order_id,
    c.customer_name,
    o.ordered_at,
    o.total_amount
FROM orders o
JOIN customers c
  ON c.customer_id = o.customer_id
WHERE o.order_status = 'REVIEW_REQUIRED'
  AND o.ordered_at >= '2026-07-01'
ORDER BY o.ordered_at DESC
LIMIT 20;

The query returns only 20 rows, but that does not mean the database reads only 20 rows.

Internally, it might perform work like this:

Scan 10 million orders
→ Filter orders by status and date
→ Join matching orders with customers
→ Sort the result
→ Return the first 20 rows

With a suitable index, however, the same query might follow a much narrower path:

Locate recent REVIEW_REQUIRED orders in an index
→ Read only the required order rows
→ Find each customer by primary key
→ Return the first 20 rows

Both execution paths produce the same result, but the amount of work is dramatically different.

EXPLAIN helps us see which path the database intends to use.

EXPLAIN shows the table access order, access methods, selected indexes, estimated row counts, and additional operations chosen by the query optimizer.

The goal is not merely to check whether an index appears in the plan.

A useful execution-plan analysis should answer these questions:

Which table is read first?

How does the database locate rows in each table?

How many rows does it expect to examine?

How many times will later operations be repeated?

Will it need an additional sort or temporary intermediate result?


EXPLAIN shows the physical strategy behind a SQL query

SQL describes the result we want.

SELECT *
FROM orders
WHERE customer_id = 1001;

The statement does not explicitly say whether the database should:

  • scan the entire table
  • use an index on customer_id
  • use a composite index
  • read table rows after finding index entries
  • apply one condition before another

Those decisions are made by the query optimizer.

The optimizer examines table statistics, available indexes, predicate selectivity, and estimated costs. It may compare several possible strategies:

Candidate plan A
Scan the entire orders table

Candidate plan B
Use an index on customer_id

Candidate plan C
Use a composite index and apply an additional filter

It then selects the plan that appears to have the lowest cost.

Placing EXPLAIN before the query reveals that decision:

EXPLAIN
SELECT *
FROM orders
WHERE customer_id = 1001;

A traditional MariaDB or MySQL-style EXPLAIN result commonly contains columns such as these:

Column Meaning
id Identifier for a query block
select_type Type of SELECT operation
table Table accessed at the current step
type Access method used for the table
possible_keys Indexes that could potentially be used
key Index actually selected
key_len Length of the index key used
ref Value or column compared with the index
rows Estimated number of rows to examine
filtered Estimated percentage of rows that pass additional filters
Extra Additional operations such as filtering, sorting, or covering-index access

Trying to interpret every column at once can make an execution plan seem more difficult than it is.

A practical reading order is:

1. Table access order
2. Access method in type
3. Candidate and selected indexes
4. Estimated rows
5. Estimated filtering
6. Extra work such as sorting or temporary results

Start by identifying the table access order

Suppose the execution plan looks like this:

id table type key rows Extra
1 o range idx_orders_status_date 1,200 Using index condition
1 c eq_ref PRIMARY 1  

For a simple join plan, this can usually be read from top to bottom:

1. Read orders first.
2. For each matching order, find the related customer.

The optimizer expects to find approximately 1,200 order rows.

For each of those rows, it uses the customer primary key to locate one customer.

Conceptually:

Find approximately 1,200 qualifying orders

For each order:
→ Read customer_id
→ Probe the customers primary-key index
→ Retrieve one customer row

The customer lookup does not happen only once.

If the first step produces 1,200 rows, the primary-key lookup may be repeated approximately 1,200 times.

1,200 estimated order rows
×
1 customer lookup per order
≈
1,200 customer index lookups

This is not an exact cost formula, but it is a useful way to understand how work multiplies across join stages.

For more complex plans containing subqueries, derived tables, CTEs, or UNION operations, the visual row order may not be enough. In those cases, id, select_type, and structured formats such as JSON become more important.


The type column describes how broadly the database searches

The type column is one of the first fields to inspect.

Despite its name, it does not describe a data type. It describes the method used to access rows in a table.

Common values include:

const
eq_ref
ref
range
index
ALL

These values are often presented from narrower access to broader access.

However, type alone does not determine whether a query is good or bad. A plan must also be evaluated using table size, estimated rows, actual rows, and execution frequency.

const

const commonly appears when a primary key or unique index is compared with a constant and the query can return at most one row.

SELECT *
FROM orders
WHERE order_id = 5001;

If order_id is the primary key, the plan may look like this:

type = const
key = PRIMARY
rows = 1

The optimizer can treat the matched row almost like a constant during the rest of the query.

eq_ref

eq_ref commonly appears in a join when each row from an earlier table can match at most one row in the current table.

SELECT
    o.order_id,
    c.customer_name
FROM orders o
JOIN customers c
  ON c.customer_id = o.customer_id;

If customers.customer_id is the primary key, each order can match at most one customer.

Current order row
→ Read customer_id
→ Probe the customer primary key
→ Return at most one row

eq_ref is generally an efficient join access method.

That does not mean the complete query is automatically fast.

If the earlier stage produces five million orders, the supposedly efficient primary-key lookup may still be repeated five million times.

eq_ref
≠
The complete query must be fast

ref

ref is commonly used when a non-unique index is searched for rows sharing the same value.

SELECT *
FROM orders
WHERE customer_id = 1001;

A customer may have many orders, so an index on customer_id can return multiple rows.

type = ref
key = idx_orders_customer

This access method also appears naturally in one-to-many joins.

range

range means that the database reads a specific range of an index.

SELECT *
FROM orders
WHERE ordered_at >= '2026-07-01'
  AND ordered_at <  '2026-08-01';

The database may locate the first matching key and continue through the index until the upper boundary is reached.

Locate the 2026-07-01 key
→ Read July entries sequentially
→ Stop before 2026-08-01

range can appear with inequalities, BETWEEN, and some forms of IN.

index

The name index can be misleading.

It does not necessarily mean that the database performed a narrow index lookup. It commonly means that it scanned the entire index.

Full table scan
versus
Full index scan

Scanning an index may still be cheaper than scanning the complete table, especially when the index is smaller or contains every column needed by the query.

However, the plan is still reading the index broadly.

The following interpretation is therefore incorrect:

An index name appears
→ The query must be performing a selective index search

ALL

ALL means a full table scan.

Read most or all rows in the table

When ALL appears on a large table for a highly selective query, it may indicate:

  • a missing index
  • an unusable index
  • an expression preventing index access
  • a type conversion
  • an inaccurate row estimate
  • an index the optimizer considers more expensive than scanning

A full table scan is not always wrong.

It may be reasonable when:

  • the table is small
  • most rows are required
  • the predicate matches a large percentage of the table
  • indexed access would require too many random table lookups
  • the optimizer correctly estimates that scanning is cheaper

Suppose 90% of orders have the COMPLETED status:

SELECT *
FROM orders
WHERE order_status = 'COMPLETED';

Even if an index exists on order_status, using it to retrieve most of the table may be more expensive than a sequential scan.

An index does not have to be used simply because it exists.


Read possible_keys and key together

possible_keys lists indexes that the optimizer considers potentially usable for the current access condition.

key shows the index selected for the plan.

For example:

possible_keys key
idx_orders_status, idx_orders_status_date idx_orders_status_date

The optimizer considered both indexes and selected the composite index.

Another plan may look like this:

possible_keys key
idx_orders_status NULL

The index is technically relevant, but the optimizer chose not to use it.

Index appears in possible_keys
→ It could support part of the condition

key is NULL
→ It is not used in the selected plan

This does not immediately mean that the optimizer made a mistake.

Check the following:

  • How many rows match the condition?
  • Does the query request most table columns with SELECT *?
  • Would indexed access require many table-row lookups?
  • Is the table small?
  • Are the statistics current?
  • Is the indexed value distribution highly skewed?

If possible_keys is also NULL, there may be no directly useful index for the current condition.

That is not necessarily a problem if scanning the table is inherently appropriate.


key_len helps reveal how much of an index is being used

Suppose the following composite index exists:

CREATE INDEX idx_orders_status_date_customer
ON orders (
    order_status,
    ordered_at,
    customer_id
);

key_len reports the length of the index key used by the access path.

It can help determine whether the plan is using only the leading column or a larger portion of the composite index.

However, it is not a simple count of index columns.

The reported byte length depends on factors such as:

  • data type
  • string length
  • character set
  • whether NULL is allowed
  • index prefix length

A value such as key_len = 8 should therefore be interpreted together with the table and index definitions, not memorized in isolation.


ref shows what the index is compared against

The ref column shows the value used to search the selected index.

It may display:

const

This means that the index is compared with a constant value, such as:

WHERE order_status = 'REVIEW_REQUIRED'

In a join, ref may contain a column from an earlier table:

app.o.customer_id

This indicates that the current table’s index is probed using orders.customer_id.


Use rows and filtered to estimate how much data moves through the plan

rows is the optimizer’s estimate of how many rows it will examine at the current step.

The key word is estimate.

rows = 1,200

does not guarantee that exactly 1,200 rows will be read at runtime.

The estimate can be inaccurate when statistics are outdated or data distribution is uneven.

filtered is the estimated percentage of those rows that will survive additional conditions.

Suppose the plan reports:

rows = 100,000
filtered = 10.00

The optimizer expects to read approximately 100,000 rows and pass about 10% of them to the next stage.

100,000 × 10%
≈ 10,000 rows

This is still only a rough estimate.

In a join, the number of rows passed from one stage affects how many times later operations may run.

Consider this plan:

table type rows filtered
o ALL 10,000,000 0.01
c eq_ref 1 100.00

Only about 1,000 order rows may survive the filter, but the database expects to examine approximately 10 million orders to find them.

Rows passed forward
≈ 1,000

Rows examined in orders
≈ 10,000,000

A query is not efficient merely because its final result is small.


Incorrect estimates can produce poor plans

Suppose the optimizer expects 1,000 REVIEW_REQUIRED orders, but the real number is two million.

Estimated
1,000 orders
→ 1,000 customer lookups

Actual
2,000,000 orders
→ 2,000,000 customer lookups

A nested-loop strategy may look inexpensive when based on the estimate but become extremely expensive at runtime.

Large estimation errors can result from:

  • stale statistics
  • strongly skewed data
  • correlated predicates
  • functions applied to indexed columns
  • implicit data-type conversions
  • parameter values with very different selectivity

When reading an execution plan, a small estimate is less meaningful if it does not match reality.


Extra reveals additional work

The Extra column describes operations not fully represented by the basic access method.

These messages should not automatically be treated as errors. Each one describes something the database expects to do.

Using where

Using where means that additional predicates are evaluated after rows are read from the selected access path.

Read candidate row
→ Evaluate WHERE condition
→ Keep the row if it passes

This can appear even when an index is used.

An index may narrow the search using some conditions, while other predicates still have to be checked afterward.

The important question is not whether Using where appears.

It is:

How many rows are read before that filtering occurs?

Using index

Using index often means that the query can be satisfied using only data stored in the index.

This is commonly associated with a covering index.

Typical indexed access
Search index
→ Locate table row
→ Read table data
→ Return result

Covering-index access
Search index
→ Read required values from the index
→ Return result

Avoiding table-row lookups can improve performance.

However, adding every requested column to an index merely to produce Using index can make the index large and expensive to maintain.

Using index condition

Using index condition is associated with Index Condition Pushdown.

The storage engine can evaluate an additional predicate while examining index entries, reducing unnecessary table-row lookups.

Read index entry
→ Evaluate additional index condition
→ Access the table row only if needed

The index may not fully determine the range, but it can still reduce the number of table rows read.

Using filesort

Using filesort means that the requested output order cannot be obtained directly from the selected index order, so a separate sorting operation is needed.

ORDER BY o.ordered_at DESC

The execution may look like this:

Read matching rows
→ Build sort input
→ Sort by ordered_at
→ Return the first 20 rows

Despite the name, filesort does not mean that the sort must always be written to a disk file.

Depending on the amount of data and available memory, the operation may happen in memory or use temporary storage.

Using filesort also does not always mean that another index should be added.

Sorting a few dozen rows may be inexpensive. Sorting several million rows before applying LIMIT 20 may be a serious bottleneck.

Using temporary

Using temporary means that the database may use an intermediate structure to process operations such as:

  • GROUP BY
  • DISTINCT
  • sorting
  • certain complex joins or derived results

Conceptually:

Read source rows
→ Build intermediate result
→ Group, deduplicate, or reorganize rows
→ Produce final result

This is not automatically a bad plan.

The important questions are how large the temporary result becomes, how often the query runs, and whether temporary storage spills beyond memory.


Reading one execution plan from beginning to end

Return to the original query:

SELECT
    o.order_id,
    c.customer_name,
    o.ordered_at,
    o.total_amount
FROM orders o
JOIN customers c
  ON c.customer_id = o.customer_id
WHERE o.order_status = 'REVIEW_REQUIRED'
  AND o.ordered_at >= '2026-07-01'
ORDER BY o.ordered_at DESC
LIMIT 20;

Assume the plan looks like this:

table type possible_keys key rows filtered Extra
o ALL idx_orders_status NULL 10,000,000 0.01 Using where; Using filesort
c eq_ref PRIMARY PRIMARY 1 100.00  

We can now read the plan step by step.

1. orders is the starting table

The first step accesses orders.

2. orders is fully scanned

type = ALL
rows = 10,000,000

The optimizer expects to examine approximately 10 million order rows.

3. A candidate index exists but is not selected

possible_keys = idx_orders_status
key = NULL

The status index is technically relevant, but the optimizer decided not to use it.

Possible reasons include:

  • too many rows have the target status
  • the status-only index does not support the date condition well
  • indexed access would require too many table lookups
  • the index does not support the requested order
  • the statistics do not reflect the real distribution

4. Most examined rows are discarded

filtered = 0.01

The optimizer expects only about 0.01% of the 10 million rows to survive.

Read 10,000,000 rows
→ Keep approximately 1,000

The difference between rows examined and rows passed forward is substantial.

5. A separate sort is required

Using filesort

The qualifying orders must be sorted by ordered_at DESC before the first 20 are returned.

6. Each qualifying order looks up one customer

customers
type = eq_ref
key = PRIMARY
rows = 1

The customer lookup itself is efficient.

The main problem is not the join to customers.

The main problem is finding and ordering the relevant rows from orders.

Not the primary bottleneck
Customer primary-key lookup

Likely bottleneck
Full scan of 10 million orders
+
Separate sorting

Without this analysis, adding another index to the customer table would optimize the wrong part of the query.


Compare the plan after changing the index

The query filters and sorts using these columns:

order_status = equality condition
ordered_at = range condition
ORDER BY ordered_at DESC

A composite index worth evaluating is:

CREATE INDEX idx_orders_status_date
ON orders (
    order_status,
    ordered_at
);

The index groups entries by status and orders entries within each status by ordered_at.

REVIEW_REQUIRED
→ ordered_at order

PAID
→ ordered_at order

Assume the new plan looks like this:

table type key rows filtered Extra
o range idx_orders_status_date 1,000 100.00 Using index condition
c eq_ref PRIMARY 1 100.00  

The new plan can be read as follows:

orders
→ Use the composite index
→ Read the required status and date range
→ Expect approximately 1,000 rows

customers
→ Use the primary key for each matching order
→ Return one customer row

Before:

Scan 10 million orders
→ Filter rows
→ Sort the surviving result

After:

Locate the relevant index range
→ Process only qualifying order candidates

The new plan appears more efficient, but the optimization process is not finished until it is measured.

Check whether:

  • actual response time improved
  • the number of examined rows decreased
  • the separate sort was removed or reduced
  • the new index increased write cost significantly
  • the index helps other important queries
  • it overlaps unnecessarily with existing indexes

An index is both a read optimization structure and an additional cost for storage, inserts, updates, and deletes.


EXPLAIN is usually an estimated plan

A traditional EXPLAIN generally shows estimates produced by the optimizer:

Estimated row count
Estimated filtering ratio
Estimated access cost

These estimates may differ from actual execution.

For example:

ItemEstimatedActual

REVIEW_REQUIRED orders 1,000 2,000,000
Customer lookups 1,000 2,000,000
Rows to sort 1,000 2,000,000

When the estimates are this inaccurate, the shape of the estimated plan may not reveal the true runtime problem.

Depending on the database product and version, actual execution statistics may be available through commands such as:

EXPLAIN ANALYZE
SELECT ...

MariaDB environments may provide related analysis commands and JSON-based formats, for example:

ANALYZE FORMAT=JSON
SELECT ...

The exact syntax and output vary by database and version, so the capabilities of the current environment should be checked.

Runtime analysis can reveal:

  • actual rows processed
  • number of loops at each stage
  • estimated versus actual row counts
  • stage-level execution times
  • actual filtering ratios

These commands may execute the query rather than only describe it.

Use them carefully in production, especially for:

  • large scans
  • long-running queries
  • operations that hold locks
  • INSERT, UPDATE, or DELETE
  • statements connected to external side effects

JSON format helps with complex plans

The traditional table format is convenient for a quick inspection of simple queries.

It can become difficult to follow when the query contains:

  • several joins
  • nested subqueries
  • CTEs
  • derived tables
  • materialization
  • UNION operations

A structured format can provide more detail:

EXPLAIN FORMAT=JSON
SELECT
    o.order_id,
    c.customer_name
FROM orders o
JOIN customers c
  ON c.customer_id = o.customer_id
WHERE o.order_status = 'REVIEW_REQUIRED';

Depending on the database and version, JSON output may expose information such as:

  • query blocks
  • nested-loop structure
  • selected access paths
  • predicates applied at each stage
  • estimated row counts
  • cost information
  • materialized subqueries
  • derived-table processing
  • predicate placement

It is usually easier to begin with the traditional table output, locate the suspicious part, and then use JSON when the execution structure requires more detail.


A practical order for reading execution plans

When analyzing a slow query, the following sequence works well.

1. Verify that the SQL returns the correct result

Check for missing join predicates, incorrect filters, unintended duplicates, and wrong aggregation.

Making an incorrect query faster is not optimization.

2. Understand the role and size of each table

Ask:

Which table is large?

Which condition should reduce the data most?

What does one result row represent?

3. Identify the access order

Check whether the plan reduces the data early or sends a large intermediate result into later joins.

4. Inspect type

When ALL or index appears on a large table, determine whether reading such a broad range is reasonable.

5. Compare possible_keys and key

Distinguish between:

  • no usable index
  • an index that exists but was not selected
  • an index that was selected but still reads many rows

6. Inspect rows and filtered

Estimate how many rows are examined and how many move to the next stage.

7. Think in terms of repeated join work

Connect the first table’s output size with the number of lookups performed against later tables.

8. Inspect Extra

Look for:

  • large additional filtering
  • separate sorting
  • temporary results
  • covering-index access
  • index-condition pushdown

9. Compare estimates with actual execution

Use runtime analysis, slow-query logs, and monitoring data where appropriate.

10. Measure again after every change

Compare index and query changes using the same data volume, parameters, and workload conditions.


Common mistakes when reading EXPLAIN

An index appears in key, so the query must be fast

A query can use an index and still read millions of entries.

Index usage
≠
Low processing cost

Always inspect the access type, estimated rows, actual rows, and lookup repetition.

ALL always means that an index should be added

A full scan may be correct for a small table or a query that needs most rows.

rows is the actual number of rows read

In a traditional plan, rows is generally an estimate based on statistics.

Using where means the plan is bad

It only means that an additional condition is evaluated after candidate rows are read.

The number of rows reaching that condition is more important.

Using filesort always means disk sorting

It means that a separate sort is required. It does not guarantee that the sort is written to disk.

Fixing the worst type value is enough

The main cost may come from:

  • a large earlier result
  • millions of repeated lookups
  • an expensive sort
  • a large temporary result
  • inaccurate estimates

The complete execution flow matters more than one column value.


Key concepts for the Information Processing Engineer exam

ItemKey meaning
EXPLAIN Displays the estimated execution plan
table Table accessed at the current step
type Table access method
possible_keys Candidate indexes
key Index selected by the optimizer
key_len Length of the index key used
ref Value compared against the index
rows Estimated number of rows examined
filtered Estimated percentage passing additional filters
Using index Query may be satisfied using only index data
Using where Additional condition applied to read rows
Using filesort Separate sorting operation required
Using temporary Temporary intermediate structure used

Common access methods can be summarized as follows:

typeKey meaning
const At most one row found through a primary or unique key
eq_ref At most one row found for each earlier join row
ref Multiple matching rows found through a non-unique index
range A specific range of an index is scanned
index The entire index is scanned
ALL The entire table is scanned

Certification questions may focus on these definitions.

In real performance work, however, the full data flow matters more than any single value.


An execution plan is the database’s work proposal

Reading EXPLAIN properly means more than checking whether the key column contains an index name.

You should be able to describe the plan in plain language:

Which table is read first?

How many rows are expected from the first step?

Which condition and index are used?

How many times is the next table searched?

Is a separate sort or temporary result required?

Do the optimizer's estimates match the real data?

An execution plan is not an unquestionable answer.

It is closer to a proposed work plan created from the optimizer’s current statistics and cost model.

That is why query analysis should combine three perspectives:

The logical result expressed by the SQL

The estimated strategy shown by EXPLAIN

The actual work measured at runtime

The key idea can be summarized in one sentence:

EXPLAIN shows where the database expects to begin reading, which indexes it plans to use, how many rows it expects to process, and what additional work it expects to perform.

A good execution plan does more than use an index.

It:

  • locates the required range efficiently
  • avoids examining unnecessary rows
  • prevents large intermediate results from flowing into later stages
  • limits repeated join lookups
  • avoids unnecessary sorting and temporary work
  • estimates the real data distribution with reasonable accuracy

SQL tuning is not the process of forcing a particular value to appear in the type column.

It is the process of reducing the total amount of data the database must read, compare, join, sort, and temporarily store to produce the result.

References

Isaac S. Lee
Faith, software, data, and everyday life.