◐ ai-teacher

🗄 SQL & Databases · Chapter 8

Query plans: reading EXPLAIN without fear

Stop guessing why a query is slow. The database will tell you exactly what it plans to do — you just have to read it bottom-up.

Study this lesson free in the app →

The basics

EXPLAIN asks the database to show its query plan — the step-by-step recipe it has chosen to answer your SQL: which tables it reads, in what order, using which method, and how many rows it expects at each step.

The problem it solves: SQL says what you want, never how to get it. The same query can take 40 ms or 95 seconds depending on whether the engine uses an index, which join method it picks, and whether it has to spill to disk. Without the plan you're guessing — adding indexes and rewriting joins at random.

The mental model: a cab app's route preview. EXPLAIN shows the planned route and estimated time. EXPLAIN ANALYZE actually drives it and shows what happened at each turn. When estimate and reality differ wildly — "expected 200 rows, got 40,000" — that gap is almost always where the problem is.

Read the plan as a tree: the most indented lines run first and feed their rows upward.

Words you must know 5

Plan node
one step: Seq Scan, Index Scan, Hash Join, Sort, HashAggregate.
Estimated vs actual rows
what the planner predicted vs what really came out.
Statistics
the planner's summary of each table's data; stale stats mean bad plans.
loops
how many times a node ran; multiply per-loop numbers by it.
Spill
work that didn't fit in memory and went to disk.

Explained

Most people optimise SQL by superstition: add an index, rearrange the joins, sprinkle DISTINCT, hope.

EXPLAIN replaces all of that with a fact. It shows the plan — which tables get read, in what order, by what method, and how many rows the engine expects at each step.

Three numbers answer almost every performance question: what it's scanning, how many rows it thinks it'll get, and how wrong that estimate turns out to be.

The route the driver actually took

You ask for a route from Shillong to Guwahati. The app says 3 hours.

It took 7.

Useless information on its own. But if you can see the route it chose — "went via the hill road, hit a diversion, waited at a level crossing" — you know precisely what to change.

EXPLAIN is the route. EXPLAIN ANALYZE is the route plus what actually happened, and the gap between the two is where the answer usually lives: the engine expected 300 rows and got 4 million, so it chose a strategy that only makes sense for 300.

Reading a plan: bottom-up, inside-out

EXPLAIN ANALYZE
SELECT c.city, count(*)
FROM rides r JOIN cities c ON r.city_id = c.id
WHERE r.ride_date >= '2026-07-01'
GROUP BY c.city;
HashAggregate  (cost=8420 rows=12)  (actual time=910 rows=12 loops=1)
  ->  Hash Join  (cost=8100 rows=402000)  (actual time=880 rows=3980221)
        Hash Cond: (r.city_id = c.id)
        ->  Seq Scan on rides r  (cost=7200 rows=402000)
                                 (actual time=520 rows=3980221)
              Filter: (ride_date >= '2026-07-01')
              Rows Removed by Filter: 36019779
        ->  Hash  (cost=12 rows=12) (actual rows=12)
              ->  Seq Scan on cities c

Read the deepest indentation first — that runs first, and results flow upward.

  1. Seq Scan on rides — reading the whole table, filtering after
  2. Seq Scan on cities → built into a hash table (tiny, fine)
  3. Hash Join — probe the hash with each ride
  4. HashAggregate — group and count

The three things to look at, in order:

Step through the plan

The fix follows directly from the plan: index or partition on ride_date so the 36 million discarded rows are never read, and ANALYZE so the estimate stops being 10× wrong.

The node types worth recognising

Access — how a table is read

| Node | Meaning | Concern? | | --- | --- | --- | | Seq Scan / Full Table Scan | read everything | ⚠️ only if the filter is selective | | Index Scan | walk the index, fetch rows | ✅ | | Index Only Scan | answered entirely from the index | ✅✅ ideal | | Bitmap Heap Scan | many index matches, fetched in page order | ✅ normal for mid-selectivity |

Joins — and this is where plans go wrong

| Node | Good when | Bad when | | --- | --- | --- | | Nested Loop | one side is tiny | both sides large → O(n×m) | | Hash Join | one side fits in memory | it spills to disk | | Merge Join | both already sorted | it must sort first |

A Nested Loop with a large actual rows on both sides is the classic disaster — usually caused by an estimate that said one side would be small.

Other signals

Two plans, same query

The same query, run on Monday and again on Friday after a data load.

Monday — 40ms

Nested Loop (actual rows=210 loops=1)
  -> Index Scan on drivers (actual rows=210)
  -> Index Scan on rides (actual rows=1 loops=210)

Friday — 95 seconds

Nested Loop (est rows=200 / actual rows=2100000 loops=1)
  -> Seq Scan on drivers (est 200 / actual 40000)
  -> Index Scan on rides (actual rows=52 loops=40000)

Plans in a warehouse look different

EXPLAIN exists in Snowflake, BigQuery and Spark too, but the vocabulary changes and so do the problems.

What you look for in a warehouse plan:

The mapping from the OLTP world:

| OLTP concern | Warehouse equivalent | | --- | --- | | Seq Scan on a big table | scanning all partitions — pruning failed | | Missing index | missing partition filter or clustering key | | Nested loop blowup | broadcast join that should have been a shuffle, or vice versa | | Sort spilling to disk | spilling to remote storage |

The skill transfers exactly: read the plan, find the step that touches the most data, ask why it's touching that much. Only the node names change.

Read plans bottom-up, deepest indentation first.

Compare est rows with actual rows. A big gap means bad statistics — and every decision after it is wrong.

Rows Removed by Filter is work you paid for and threw away.

Words to know

Query planThe steps the engine will take to execute a query, shown as a tree.
EXPLAINShows the plan and its cost estimates, without running the query.
EXPLAIN ANALYZERuns the query and shows estimates alongside what actually happened.
Sequential scanReading every row of a table — fine when the filter isn't selective.
Nested loop joinFor each row on one side, look up the other. Great when one side is tiny, disastrous otherwise.
Hash joinBuild a hash of the smaller side, probe with the larger. Spills to disk if it doesn't fit.
Cardinality estimateThe planner's guess at how many rows a step will produce — the root of most bad plans.
StatisticsSampled data distributions the planner uses; refreshed by ANALYZE.
SpillingWriting intermediate results to disk because they don't fit in memory.
Partition pruningThe warehouse equivalent of index use — skipping whole files a filter excludes.

In two minutes

Test yourself

1. How do you read a query plan?

  1. Top to bottom
  2. Bottom-up — the deepest indentation runs first and results flow upward
  3. Left to right
  4. In the order of the SQL text
Show the answer

Bottom-up — the deepest indentation runs first and results flow upward — It's a tree. Leaves execute first; each node feeds its parent.

2. est rows=200 but actual rows=40000. What does that mean?

  1. The query is wrong
  2. Statistics are stale — and every plan decision after that node was made on bad information
  3. The table is corrupt
  4. Nothing important
Show the answer

Statistics are stale — and every plan decision after that node was made on bad information — A planner is only as good as its statistics. ANALYZE is the cheapest first fix and often resolves the symptom entirely.

3. Rows Removed by Filter: 36,019,779 tells you what?

  1. The filter is working well
  2. 36M rows were read and immediately discarded — work you paid for
  3. There is a syntax problem
  4. The index is being used
Show the answer

36M rows were read and immediately discarded — work you paid for — It points directly at a missing index or partition filter — data that should never have been read at all.

4. When is a Nested Loop join a disaster?

  1. Always
  2. When both sides are large — it becomes O(n x m), usually chosen from a bad estimate
  3. When one side is tiny
  4. On indexed columns
Show the answer

When both sides are large — it becomes O(n x m), usually chosen from a bad estimate — Check the loops= count. A node returning 52 rows with loops=40000 processed over two million.

5. A job has gotten slower every month with no code change. First hypothesis?

  1. Someone changed the query
  2. It's scanning something that grows — likely full history where it should be incremental
  3. The database needs restarting
  4. Network latency
Show the answer

It's scanning something that grows — likely full history where it should be incremental — Look for what's growing rather than what's wrong. 'Rows Removed by Filter: 0' is the specific tell that a filter stopped filtering.

6. What is the difference between EXPLAIN and EXPLAIN ANALYZE?

  1. None — ANALYZE just formats the output
  2. EXPLAIN runs the query; ANALYZE only estimates
  3. ANALYZE updates table statistics before planning
  4. EXPLAIN only plans; EXPLAIN ANALYZE executes the query and reports actual rows and timings
Show the answer

EXPLAIN only plans; EXPLAIN ANALYZE executes the query and reports actual rows and timings — Plain EXPLAIN never touches the data. EXPLAIN ANALYZE really runs it — which is why it shows truth, and why it's dangerous on UPDATE or DELETE. The separate ANALYZE command (no EXPLAIN) is the one that refreshes statistics.

There are 9 questions, 3 puzzles and 7 recall cards for this lesson in the app — the recall cards come back on later days, just before you would forget.

Learn it so it stays. Read it, test yourself, then recall it on two different days. The app schedules that for you, free.

Start this lesson →