How to Read an SQL EXPLAIN Plan — A Practical Guide

Published: September 11, 2026 | Updated: September 11, 2026

Every database ships with a tool that tells you exactly why your query is slow, and most developers never open it. EXPLAIN prints the optimizer's execution plan: which tables it reads, in what order, which indexes it uses, and how many rows it expects to touch. Once you can read that output, query tuning stops being guesswork.

This guide walks through the SQL EXPLAIN plan column by column, ranks the type values from best to worst, lists the red flags that should make you stop scrolling, and finishes with a real slow query fixed by a single index — with the before-and-after plans to prove it. The examples use MySQL/InnoDB syntax; PostgreSQL and SQL Server equivalents are covered at the end.

Table of Contents
  1. 1. What an EXPLAIN Plan Actually Shows
  2. 2. The Columns: id, select_type, table, type, key, rows, Extra
  3. 3. The type Column, Ranked Best to Worst
  4. 4. Red Flags: ALL, Using filesort, Using temporary
  5. 5. Case Study: Fixing a Slow Query with One Index
  6. 6. EXPLAIN in PostgreSQL and SQL Server
  7. 7. A Practical Reading Workflow
  8. FAQ

1. What an EXPLAIN Plan Actually Shows

When you run a query, the optimizer picks a strategy before touching any data: which index to use, which table to read first in a JOIN, whether to sort with an index or in memory. EXPLAIN shows you that strategy without running the query. Prepend it to any SELECT, INSERT, UPDATE, or DELETE:

EXPLAIN SELECT id, order_date, total
FROM orders
WHERE customer_id = 1042
ORDER BY order_date DESC;

You get one row of output per table access in the query. A three-table JOIN produces at least three rows. Each row describes how the engine plans to fetch rows from one table, and the rows together tell you the join order.

Two variants are worth knowing:

💡 Pro Tip: Plain EXPLAIN is safe on production — it does not execute the query. EXPLAIN ANALYZE does execute it, so be careful running it on a heavy UPDATE or DELETE; wrap the statement in a transaction and roll back if needed.

2. The Columns: id, select_type, table, type, key, rows, Extra

Here is a typical MySQL EXPLAIN output for a two-table join, slightly trimmed to fit:

+----+-------------+-------+------+------------------+---------+---------+-----------------+--------+-----------------------+
| id | select_type | table | type | possible_keys    | key     | key_len | ref             | rows   | Extra                 |
+----+-------------+-------+------+------------------+---------+---------+-----------------+--------+-----------------------+
|  1 | SIMPLE      | c     | const| PRIMARY,idx_email| PRIMARY | 4       | const           |      1 | Using index           |
|  1 | SIMPLE      | o     | ref  | idx_customer     | idx_customer | 4  | shopdb.c.id     |    214 | Using where; Using index |
+----+-------------+-------+------+------------------+---------+---------+-----------------+--------+-----------------------+

The columns you actually need to read, in the order that matters:

ColumnWhat it tells youHow to read it
idIdentifier of the SELECT within the queryRows with the same id are one query block, executed top to bottom. A higher id means a subquery, which runs first. If you see ids 1, 2, 3, the optimizer is executing nested subqueries — often a sign to rewrite with a JOIN.
select_typeKind of SELECTSIMPLE = no subqueries. PRIMARY = outermost of a nested query. SUBQUERY / DEPENDENT SUBQUERY = subquery; DEPENDENT means it re-runs per outer row (expensive). DERIVED = subquery in the FROM clause, materialized into a temp table.
tableWhich table (or alias) this row describesRead the table rows top to bottom to see the join order. Values like <derived2> or <subquery3> mean a materialized intermediate result, not a base table.
typeAccess method — the single most important columnRanges from system/const (one-row lookup, great) down to ALL (full table scan, usually bad). Full ranking in section 3.
keyThe index actually usedNULL means no index was used. Compare against possible_keys: if a useful index is listed there but key is NULL or different, the optimizer chose not to use it — usually because of low selectivity or stale statistics.
rowsEstimated rows examined at this stepAn estimate from index statistics, not an exact count. Multiply across join rows to gauge total work: 500 × 2,000 examined rows is a million-row nested loop. If rows is near the table size, you're scanning everything.
ExtraEverything else the engine doesThe warning panel. Using index is good (index-only scan). Using filesort, Using temporary, and Using where on huge row counts are the ones to investigate — see section 4.

Two supporting columns deserve a mention: possible_keys (indexes the optimizer could have used — if it's NULL, no index on your WHERE columns exists at all) and key_len (how many bytes of the index are used — a shorter key_len than the full composite index means only a prefix of the index matched, classic evidence of the leftmost-prefix rule at work).

3. The type Column, Ranked Best to Worst

The type column describes how rows are located. Memorize this ranking — it is the fastest way to read EXPLAIN output at a glance:

RanktypeMeaningTypical trigger
1systemTable has exactly one rowSystem tables; you'll rarely see it in app code
2constAt most one matching row, read onceWHERE id = 42 on a PRIMARY KEY or UNIQUE index
3eq_refOne matching row per combination from previous tablesJOIN on a PRIMARY_KEY / UNIQUE NOT NULL column — the best join type
4refAll rows matching a non-unique index valueWHERE customer_id = 1042 with a non-unique index on customer_id
5rangeIndex scan over a bounded rangeBETWEEN, >, <, IN (...), LIKE 'abc%'
6indexFull scan of the index tree (not the table)Covering-index scans; better than ALL, still reads every index entry
7ALLFull table scanNo usable index — the thing you're usually hunting for

Two practical readings of this table:

One honest caveat: type=ALL on a 500-row lookup table is fine — reading 500 sequential rows beats an index dive. The ranking tells you what to investigate, not what to panic about. Table size and selectivity decide.

4. Red Flags: ALL, Using filesort, Using temporary

Red flag #1 — type = ALL on a big table

A full table scan means every row is read into the buffer pool to test your WHERE condition. If rows is in the hundreds of thousands and possible_keys is NULL, you're missing an index entirely. If possible_keys lists an index but key is NULL, the optimizer rejected it — common causes:

Red flag #2 — Using filesort

Using filesort in Extra means the ORDER BY couldn't be served by index order, so MySQL sorts the matched rows itself. Sorting 20 rows is nothing; sorting 200,000 rows before throwing away all but 10 is the classic slow pagination query. The fix is a composite index that matches WHERE first, then ORDER BY:

-- Query: filter by customer, sort by date, take latest 10
SELECT id, order_date, total
FROM orders
WHERE customer_id = 1042
ORDER BY order_date DESC
LIMIT 10;

-- Index that removes both the scan AND the filesort:
ALTER TABLE orders ADD INDEX idx_cust_date (customer_id, order_date);

With (customer_id, order_date), MySQL jumps to customer 1042's entries via the index, walks them backwards (the index is already sorted by date within that customer), and stops after 10 rows. Extra should then show Using where with no filesort.

Red flag #3 — Using temporary

Using temporary means MySQL builds an internal temp table — typical for GROUP BY on an unindexed column, DISTINCT over a join, or UNION. Temp tables start in memory but spill to disk past tmp_table_size/max_heap_table_size, and disk temp tables are brutally slow. Mitigations:

Also watch for DEPENDENT SUBQUERY in select_type: the subquery re-executes for every outer row. A 10,000-row outer table turns a cheap subquery into 10,000 executions. Rewrite as a JOIN or a materialized derived table.

Paste Your Query, See the Plan Instantly

Our free EXPLAIN tool parses and visualizes your query's execution plan in the browser — no database connection required. Perfect for reading plans before you tune.

Open SQL EXPLAIN Tool →

5. Case Study: Fixing a Slow Query with One Index

Here's a real shape of problem: an e-commerce order search that takes 4.2 seconds on a 1.2-million-row table. The schema (trimmed):

CREATE TABLE orders (
  id           BIGINT PRIMARY KEY AUTO_INCREMENT,
  customer_id  INT NOT NULL,
  status       VARCHAR(20) NOT NULL,
  order_date   DATETIME NOT NULL,
  total        DECIMAL(10,2) NOT NULL,
  KEY idx_status (status)
) ENGINE=InnoDB;

-- The slow query:
SELECT id, order_date, total
FROM orders
WHERE customer_id = 1042 AND status = 'shipped'
ORDER BY order_date DESC
LIMIT 10;

Before — the EXPLAIN plan

EXPLAIN SELECT id, order_date, total
FROM orders
WHERE customer_id = 1042 AND status = 'shipped'
ORDER BY order_date DESC LIMIT 10;

+----+-------------+--------+------+---------------+------------+---------+-------+---------+--------------------------------------------------------------+
| id | select_type | table  | type | possible_keys | key        | key_len | ref   | rows    | Extra                                                        |
+----+-------------+--------+------+---------------+------------+---------+-------+---------+--------------------------------------------------------------+
|  1 | SIMPLE      | orders | ALL  | idx_status    | NULL       | NULL    | NULL  | 1198432 | Using where; Using filesort                                  |
+----+-------------+--------+------+---------------+------------+---------+-------+---------+--------------------------------------------------------------+

Reading this plan like a checklist:

The fix — one composite index

ALTER TABLE orders
  ADD INDEX idx_cust_status_date (customer_id, status, order_date);

Column order matters: equality predicates first (customer_id, status), then the range/sort column (order_date). This single index serves the filter and the ORDER BY.

After — the EXPLAIN plan

+----+-------------+--------+-------+----------------------+----------------------+---------+------+------+--------------------------------------------+
| id | select_type | table  | type  | possible_keys        | key                  | key_len | ref  | rows | Extra                                      |
+----+-------------+--------+-------+----------------------+----------------------+---------+------+------+--------------------------------------------+
|  1 | SIMPLE      | orders | ref   | idx_cust_status_date | idx_cust_status_date | 86      | const,const | 19 | Using where; Using index               |
+----+-------------+--------+-------+----------------------+----------------------+---------+------+------+--------------------------------------------+

What changed:

SignalBeforeAfter
typeALL (full scan)ref (index lookup)
rows examined1,198,43219
filesortUsing filesortGone — index supplies date order
Table accessEvery column of every rowUsing index — covering, table never touched
Wall time4.2 s3 ms

Because the query only selects id, order_date, and total — and id is implicitly in every InnoDB secondary index — extending the index to (customer_id, status, order_date, total) would make it fully covering and print Using index without touching the clustered index at all. In this plan, Using index already appears because the needed columns fit the index plus primary key. 1.2M rows examined dropped to 19: that's what reading EXPLAIN output is for.

💡 Pro Tip: Confirm with EXPLAIN ANALYZE — it shows actual time and actual rows per step. If actual rows wildly exceed the estimate, run ANALYZE TABLE orders; to refresh optimizer statistics before drawing conclusions.

6. EXPLAIN in PostgreSQL and SQL Server

The MySQL columns above are InnoDB-specific, but the concepts port directly.

PostgreSQL

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, order_date, total
FROM orders
WHERE customer_id = 1042 AND status = 'shipped'
ORDER BY order_date DESC
LIMIT 10;

PostgreSQL prints a tree, not a table. Key node types to recognize: Seq Scan (= MySQL's ALL), Index Scan, Index Only Scan (= covering), Bitmap Heap Scan (index used as a row filter — good for medium selectivity), Sort with a large row count (= filesort), and Hash Join / Nested Loop. With ANALYZE, compare estimated rows vs actual rows at each node — a 100× mismatch is where plans go wrong, usually fixed with better statistics targets or a rewritten predicate.

SQL Server

SET STATISTICS XML ON;
SELECT id, order_date, total
FROM orders
WHERE customer_id = 1042 AND status = 'shipped'
ORDER BY order_date DESC;
SET STATISTICS XML OFF;

The XML plan (or the graphical plan in SSMS) flags Table Scan / Clustered Index Scan (= ALL), Sort operators (= filesort), and Hash Match with spools (= temporary). SSMS even labels "missing index" suggestions directly on the plan — treat them as candidates, not commands; verify each with the same before/after method as above.

7. A Practical Reading Workflow

When a query is slow, work through the plan in this order:

  1. Scan the type column first. Any ALL or index on a large table? That's your suspect.
  2. Check key against possible_keys. NULL with candidates listed → optimizer rejected the index (selectivity, functions on columns, type mismatch). NULL with no candidates → create the index.
  3. Look at rows. Estimates near table size confirm a scan; huge products across join rows confirm a nested-loop blowup.
  4. Read Extra last, but seriously. Using filesort and Using temporary on large inputs are where the remaining milliseconds hide after indexing.
  5. Verify with EXPLAIN ANALYZE. Estimates can lie; actuals can't. If actual rows deviate 10× from estimates, refresh statistics before tuning further.
  6. Change one thing at a time — add the index, re-run EXPLAIN, confirm type improved and rows dropped. Then move to the next table in the plan.

Reading plans is a habit, not a fire drill. Run EXPLAIN on every query that touches a table with more than ~100k rows before it ships, not after it pages you at 3 a.m. And when you paste the offending monster query into EXPLAIN, paste it through a SQL formatter first — a formatted query makes the plan's table order and predicates far easier to line up.

FAQ — Reading SQL EXPLAIN Plans

What does type=ALL mean in an EXPLAIN plan?

It means MySQL performs a full table scan, reading every row to evaluate the WHERE condition. On small tables this is fine and can even beat an index lookup. On large tables it's the most common slow-query cause and usually points to a missing index on a WHERE or JOIN column.

What is the difference between EXPLAIN and EXPLAIN ANALYZE?

EXPLAIN shows the planned strategy from optimizer statistics without executing the query. EXPLAIN ANALYZE (MySQL 8.0.18+, PostgreSQL) runs the query and reports real timings and actual row counts per step. Use ANALYZE whenever estimated and actual row counts might disagree.

Why is Using filesort bad?

It means ORDER BY couldn't use index order, so MySQL sorts rows itself — in memory, or on disk past a threshold. Sorting small results is cheap; sorting hundreds of thousands of rows to return the top 10 is not. A composite index matching your WHERE columns followed by the ORDER BY column usually eliminates it.

What does the rows column tell me?

It's the optimizer's estimate of rows examined at that step, not rows returned. Multiply estimates across join steps to gauge total work. Values near table size mean you're scanning everything; a large gap between estimate and actual means stale statistics — run ANALYZE TABLE.

Does an index always improve an EXPLAIN plan?

No. If a predicate matches a large share of the table (roughly 20–30% or more), sequential scanning can beat thousands of random index lookups, and the optimizer will ignore your index on purpose. Indexes also add write overhead and disk usage. Always verify with EXPLAIN ANALYZE that the plan changed and the query actually got faster.