How to read an EXPLAIN plan
EXPLAIN shows the plan the database intends to run; EXPLAIN ANALYZE runs the query and shows what actually happened. Almost every “why is this query slow?” answer is in the second one. This guide walks a real PostgreSQL plan and a real MySQL plan line by line, then lists the node types and the numbers worth checking.
One caveat before you start: EXPLAIN ANALYZE executes the statement. On UPDATE, DELETE or INSERT, wrap it in a transaction you roll back.
Reading a PostgreSQL plan
A plan is a tree printed with the root on top. Each -> is a child node feeding its parent, and every node reports what the planner expected next to what it got.
Reading a MySQL plan
MySQL prints the same tree with different vocabulary. The same query, same data:
Node types you’ll actually meet
| Node | What it does | When it's fine / suspicious |
|---|---|---|
| Seq Scan / Table scan | Reads every row of the table. | Fine on small tables and when you genuinely need most rows. A red flag on a large table behind a selective filter. |
| Index Scan | Walks an index, then fetches matching rows from the table. | What you usually want for selective filters. Degrades if the filter matches a large share of the table. |
| Index Only Scan | Answers the query from the index alone, no table fetch. | The fastest shape. Needs every selected column in the index and a recently vacuumed table. |
| Bitmap Heap Scan | Collects matching row locations first, then reads the table in physical order. | Postgres' middle ground when a filter matches too many rows for an Index Scan but not the whole table. |
| Nested Loop | Probes the inner side once per outer row. | Great with few outer rows and an indexed inner side. Check loops — that's where it goes wrong. |
| Hash Join | Builds a hash table over one side, probes it with the other. | The default for joining two large unindexed sets. Watch memory: spilling to disk shows up as batches > 1. |
| Merge Join | Walks two already-sorted inputs in step. | Cheap when both sides arrive sorted (indexes), expensive when the plan has to sort them first. |
| Sort / HashAggregate | Materializes rows to order or group them. | An unavoidable cost unless an index already provides the order. Check for disk spills on large inputs. |
What to look at first
Don’t read the plan top to bottom. Find the node with the largest self time — parent timings include their children, so subtract the child’s actual time from the parent’s — and check four numbers on it:
Common red flags and what they mean
Once you know which node hurts, the fix is usually an index or a rewrite — see optimize a SQL query for the rewrites that make a filter sargable and the indexes that remove a Sort node.
Have a query but no plan yet?
Paste the SQL into the formatter to make it readable, then ask the AI explainer what the query does — which tables drive the result, where the filters bite, and where a LEFT JOIN or HAVING quietly changes the row set. Run EXPLAIN ANALYZE on your own database for the timings; use the explainer to understand the SQL itself.
Free account, 3 free AI credits. Formatting stays free without signing in.
- PostgreSQL —
EXPLAIN (ANALYZE, BUFFERS) <query>; - MySQL 8.0.18+ —
EXPLAIN ANALYZE <query>; - SQL Server —
SET STATISTICS PROFILE ON, or the actual execution plan in SSMS. - SQLite —
EXPLAIN QUERY PLAN <query>;
Frequently asked questions
What is the difference between EXPLAIN and EXPLAIN ANALYZE?
EXPLAIN only plans the query and prints the planner's estimates — nothing runs. EXPLAIN ANALYZE actually executes the query and adds real timings and real row counts next to the estimates, which is what lets you spot a bad estimate. Because it runs the query, never use EXPLAIN ANALYZE on an INSERT, UPDATE or DELETE outside a transaction you intend to roll back.
Is a Seq Scan always bad?
No. A sequential scan is the right plan when the query touches a large share of the table or the table is small enough to sit in memory — an index lookup per row would be slower. It is worth investigating when the filter is highly selective and the scan still reads millions of rows, which usually means a missing index or a predicate the planner cannot use.
How do I read the cost numbers?
cost=4821.19..4903.44 is a pair: the estimated cost to return the first row, then to return them all. The unit is arbitrary — one sequential page read is 1.0 — so the numbers only mean something relative to each other within the same plan. Compare nodes, not plans from different databases.
What does it mean when estimated rows and actual rows differ wildly?
The planner's statistics are stale or the predicate is one it cannot estimate (a function call, a correlated column pair). A 100× gap is the single most common root cause of a bad plan: run ANALYZE on the table, and consider extended statistics for correlated columns.
Can I paste my plan here to get it explained?
Yes — the AI explainer accepts both the query and its plan and walks through what each node does. Formatting the SQL first makes the plan far easier to line up against the query.