SQL EXPLAIN — Nested Loop on 5M Rows Caused 12-Min Query
Stale statistics on a 5M-row table: planner estimated 100 rows, chose Nested Loop, caused 12-min query.
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
- ✓Deep production experience
- ✓Understanding of internals and trade-offs
- ✓Experience debugging complex systems
- EXPLAIN shows the query plan the database will use; EXPLAIN ANALYZE actually runs the query and shows real timings
- Seq Scan = full table scan — every row read — usually the problem node to fix
- Index Scan = B-tree traversal + heap fetch per row; Index Only Scan = no heap fetch (fastest)
- Cost is in arbitrary units: (startup_cost..total_cost rows=estimated width=bytes)
- Nested Loop = one table drives, the other is looked up per row — good for small outer tables; Hash Join = build hash table from smaller table, probe with larger — good for large tables
- Loops=N on an inner node means that node ran N times — the O(n²) signal for correlated subqueries
Every slow query has a story, and EXPLAIN is how you read it. A query can be written in dozens of equivalent ways, and the database makes a decision about the most efficient path to execute it. EXPLAIN shows you that decision — like the GPS navigation plan before you drive. EXPLAIN ANALYZE is like driving the route and recording how long each segment actually took.
Every slow query has a story, and EXPLAIN is how you read it. In production, a query that ran fine on 10,000 rows can grind a system to a halt at 10 million. The difference isn't always the query itself — it's the execution plan the query optimiser chose. Developers who can read execution plans diagnose slow queries in minutes instead of hours of guesswork.
This guide teaches you to read execution plans the same way a senior DBA does: starting from the innermost node and reading the cost signals, not from the top down. By the end, you'll know the difference between every major node type, what the cost numbers actually mean, and how to use the plan to drive targeted fixes.
Why Your Query Is Slow: The Execution Plan Tells the Real Story
An SQL EXPLAIN execution plan is the database's step-by-step recipe for retrieving your requested data. It shows how the query optimizer chose to access tables, join them, and filter rows — including the algorithms used (e.g., nested loop, hash join, sequential scan) and their estimated costs. The plan is the only reliable way to understand why a query runs for minutes instead of milliseconds.
Execution plans are tree structures: each node represents an operation (scan, join, sort) with its own cost, row estimate, and actual row count when run with ANALYZE. The critical property is that the optimizer's estimates can be wildly wrong due to outdated statistics or complex predicates. A nested loop join with 5 million rows on the outer side and no index on the inner side will produce O(n*m) complexity — that's 25 trillion comparisons, explaining a 12-minute runtime.
Use EXPLAIN (ANALYZE, BUFFERS, TIMING) on any query that takes longer than 100ms in production. Without it, you're guessing. The plan reveals exactly where time is spent: sequential scans on large tables, missing indexes, or bad join order. In real systems, a single missing index can turn a 50ms query into a 5-minute disaster — the execution plan shows you the smoking gun.
How to Read an Execution Plan — The Fundamentals
An execution plan is a tree. The query planner breaks your SQL into operations and arranges them from innermost (raw data retrieval) to outermost (final result). You read a plan from the inside out — the innermost nodes execute first.
Every node in the plan shows three things: the operation type (Seq Scan, Index Scan, Hash Join), the cost estimate (startup_cost..total_cost), and the row and width estimates. When you run EXPLAIN ANALYZE, you also get the actual time and actual rows — the ground truth.
The most important comparison: estimated rows vs actual rows. When these diverge by an order of magnitude, the planner is working from stale statistics and the plan choice may be wrong. A planner that thinks a table has 1,000 rows when it has 1,000,000 will choose algorithms designed for small data.
Seq Scan, Index Scan, and Index Only Scan — The Core Node Types
Three scan types appear in most queries. Understanding what each does determines what fix to apply.
Seq Scan (Sequential Scan) reads every page of the table in order. This is not always wrong — on small tables or when the query returns >15-20% of all rows, a sequential scan is cheaper than following millions of index pointers. But Seq Scan on a large table with a selective WHERE clause is the most common performance problem in SQL.
Index Scan uses a B-tree index to find matching row locations, then fetches each matching row from the heap (the main table storage). The index narrows the search; the heap provides the full row data. This is the standard fix for a Seq Scan — add an index on the WHERE column.
Index Only Scan is the fastest path — all data the query needs is in the index itself (a covering index), so the heap is never accessed. Zero heap fetches. Achievable by adding INCLUDE columns to the index definition.
Join Algorithms — Nested Loop, Hash Join, Merge Join
Three join algorithms appear in execution plans. The planner chooses based on estimated table sizes, available indexes, and whether the data is sorted.
Nested Loop Join drives with one table (the outer loop) and probes the inner table once per outer row. Fast when the outer table is small and the inner table has an index on the join column. Catastrophic when the outer table is large — the inner probe repeats N times where N is the outer row count. The loops=N in the inner node of EXPLAIN ANALYZE reveals this.
Hash Join builds a hash table from the smaller table, then probes it for each row of the larger table. Two passes over the data, but O(n+m) rather than O(n×m). The standard choice for large table joins. Requires memory — if the hash table spills to disk (HashBatches > 1 in EXPLAIN output), performance degrades significantly.
Merge Join requires both tables to be sorted on the join column. Extremely fast when both sides are already sorted or can use an index scan in sorted order. Rarely chosen by the planner unless sort order is already available.
Statistics, ANALYZE, and Why Estimates Go Wrong
The query planner does not read your data to make decisions — it reads statistics. PostgreSQL maintains per-column statistics: the number of distinct values, the most common values and their frequencies, and a histogram of the value distribution. These statistics live in pg_statistic and are updated by VACUUM ANALYZE.
When statistics are stale — after a bulk insert, a data migration, or a period of high insert/delete volume — the planner's row estimates can be orders of magnitude off. A planner that thinks a table has 100,000 rows when it has 5,000,000 will choose Nested Loop joins instead of Hash Joins, index scans instead of seq scans on highly selective queries, and estimate sort operations as cheap when they are expensive.
The practical workflow: whenever a query plan looks wrong (estimated rows diverges greatly from actual rows in EXPLAIN ANALYZE), run ANALYZE on the affected table, then re-run EXPLAIN ANALYZE. In most cases the plan improves immediately.
EXPLAIN ANALYZE — The Only Honest Estimator
The plain EXPLAIN lies to you. It shows the planner's guess — what it thinks will happen based on table statistics that might be stale, skewed, or just wrong. You see an Index Scan with cost 12.42 and assume your query is fine. Then it runs for three seconds in production.
EXPLAIN ANALYZE actually executes the query. It shows real timings, real row counts, and the actual number of loops for each node. The gap between "estimated rows" and "actual rows" is where performance goes to die. When you see estimated=1 vs actual=1,000,000, you know the planner chose a terrible join order or scan method based on bad stats.
Run EXPLAIN ANALYZE on a representative dataset — not a local dev table with three rows. Capture the output before a migration or schema change. Compare the before and after. That's how you catch regressions before they hit users.
The time overhead is real, especially on writes (INSERT/UPDATE/DELETE). Don't run ANALYZE on production OLTP during peak hours. Use a read replica or a staging environment with production-scale data.
Why the Planner Chooses Wrong — and How to Fix It
You've seen it: a query that was fast yesterday is slow today. No code change. No schema change. The execution plan flipped from an Index Scan to a Sequential Scan. Your first instinct is wrong — don't add an index hint or force a plan. Fix the root cause.
The planner's job is to minimize total cost. It relies on table statistics: row counts, null fractions, average column width, distribution histograms. When those stats are stale — after massive inserts, deletes, or bulk updates — the planner guesses wrong. A table that had 1,000 rows an hour ago now has 10,000,000. The planner still thinks a Seq Scan is cheap because it expects 1,000 rows. Oops.
Run ANALYZE after bulk operations. Not just once — set autovacuum thresholds that match your write volume. For tables with heavy churn (event logs, audit trails), consider increasing default_statistics_target to 1000 or higher for columns used in WHERE and JOIN predicates. That captures histograms with 1000 buckets instead of the default 100. More buckets = better cardinality estimates for skewed data.
Still wrong? Use pg_stats to inspect column frequency arrays and bucket boundaries. If the planner doesn't see a value's histogram entry, it assumes uniform distribution. Real data is never uniform.
Parallel Plans: Why Your 64-Core Machine Idles While You Wait
Most devs assume throwing hardware at a slow query makes it faster. The planner disagrees. It decides whether a parallel plan is worth the overhead — and it's often wrong.
Parallelism kicks in when the planner estimates a query will process enough rows to justify splitting work across workers. It starts with a Gather or Gather Merge node, then fans out to partial scans or joins. The problem? If your table is small or your WHERE clause is selective, the coordinator spends more time orchestrating than the workers spend executing. You get slower results on a screaming-fast machine.
Check parallel_workers and parallel_tuple_cost in the plan. A Seq Scan on a 10M-row table should go parallel. An Index Scan returning 100 rows should not. Force parallelism with a hint only after confirming the planner's row estimate is correct — otherwise you're just burning CPU cycles.
Subquery vs. CTE: The Planner's Dirty Secret About Materialization
Every dev loves a CTE for readability. The planner loves it for a different reason: it materializes the CTE once and reuses it. That sounds great until the CTE returns 10M rows and the outer query only needs 10.
Subqueries get flattened into the main plan. The planner can push predicates down, use indexes, and join in smarter order. CTEs act as optimization fences. Once materialized, the planner treats the result as a static table — no pushdown, no index use. You're stuck with a full scan on a temp dataset.
Check the plan for 'CTE Scan' or 'Materialize' nodes. If you see CTE Scan on my_cte followed by a sequential scan, you're paying the materialization tax. Rewrite as a subquery or a LATERAL join. Exceptions: recursive CTEs and CTEs referenced more than once. Those genuinely benefit from materialization.
NOT MATERIALIZED hint after the AS keyword. PostgreSQL 12+ respects it. Oracle and SQL Server ignore it — know your engine.Reading EXPLAIN Plans: Seq Scan, Index Scan, Bitmap Scan, Hash Join
Understanding the different node types in an EXPLAIN plan is crucial for diagnosing query performance. A Seq Scan reads the entire table row by row, which is efficient for small tables but disastrous for large ones. An Index Scan uses an index to locate rows, but may still access the heap for non-indexed columns. A Bitmap Scan combines multiple indexes or handles large result sets by creating a bitmap of matching pages, then fetching rows in physical order. A Hash Join builds a hash table on one input and probes it with the other, ideal for equi-joins on unsorted data. For example, consider a query joining two tables:
``sql EXPLAIN SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE c.country = 'US'; ``
A typical plan might show a Hash Join: the planner scans customers (filtering by country), builds a hash table, then scans orders and probes the hash. If no index exists on o.customer_id, you might see a Seq Scan on orders, which could be slow. Adding an index on orders.customer_id can change the plan to an Index Scan or even a Nested Loop. Recognizing these node types helps you identify where the planner is spending time and whether an index or query rewrite is needed.
EXPLAIN ANALYZE: Actual vs Estimated Rows and Timing
EXPLAIN ANALYZE executes the query and provides actual row counts and timing, revealing discrepancies between the planner's estimates and reality. Large mismatches indicate stale statistics or poor cardinality estimates. For example:
``sql EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending'; ``
The output might show rows=10000 (estimated) vs rows=500000 (actual). This can lead to a poor join order or wrong join algorithm. The actual time includes startup and total time per node, helping you identify the most expensive step. If a Hash Join estimates 1000 rows but actual is 1 million, the hash table may spill to disk, causing high I/O. To fix, run ANALYZE to refresh statistics, or increase default_statistics_target for better histogram detail. Also, EXPLAIN (ANALYZE, BUFFERS) adds buffer hit/miss info, showing whether the query is I/O bound. For instance, shared hit=1000 means data was in cache, while shared read=500 indicates disk reads. This guides tuning of shared_buffers or query patterns.
Visual Explain Tools: pgAdmin, MSSQL Execution Plan, MySQL Workbench
Visual explain tools provide a graphical representation of execution plans, making it easier to spot expensive operations. pgAdmin offers a 'Explain' button that shows a tree with node costs, row estimates, and actuals if ANALYZE is used. You can hover over nodes for details. MSSQL Management Studio displays the execution plan as a flowchart, with thicker lines indicating higher cost. It also shows missing index suggestions. MySQL Workbench has an 'Explain' tab that visualizes the plan, though it's less detailed than pgAdmin. For example, in pgAdmin, after running EXPLAIN, click 'Explain' -> 'Analyze' to get a visual tree. The width of arrows indicates relative cost. You can also use third-party tools like Depesz (online) or PEV (PostgreSQL Explain Visualizer) for deeper analysis. These tools highlight sequential scans, nested loops, and other expensive nodes. They also show the percentage of total time spent in each node. For MSSQL, the actual execution plan (Ctrl+M) includes per-operator row counts and CPU time. MySQL Workbench's visual explain shows table access methods and join types. Using these tools, you can quickly identify that a Nested Loop join on 5M rows is the culprit, as in the article's example.
A Nested Loop Join on a 5-Million-Row Table Caused a 12-Minute Query
- Run ANALYZE on large tables after bulk inserts or migrations — stale statistics cause wrong plan choices
- Always run EXPLAIN ANALYZE after any significant data volume change to verify the plan has not regressed
- The planner estimates rows based on statistics — if the estimate is wildly off from actual rows, statistics are stale
EXPLAIN ANALYZE SELECT * FROM orders WHERE customer_id = 42;SELECT indexname FROM pg_indexes WHERE tablename = 'orders';| File | Command / Code | Purpose |
|---|---|---|
| explain_analyze_reading.sql | EXPLAIN | How to Read an Execution Plan |
| scan_types_diagnosis.sql | EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending'; | Seq Scan, Index Scan, and Index Only Scan |
| join_algorithms_explain.sql | SET enable_hashjoin = off; | Join Algorithms |
| statistics_and_analyze.sql | SELECT | Statistics, ANALYZE, and Why Estimates Go Wrong |
| ExplainAnalyzeCost.sql | EXPLAIN (ANALYZE, BUFFERS, TIMING) | EXPLAIN ANALYZE |
| FixBadPlan.sql | SHOW default_statistics_target; | Why the Planner Chooses Wrong |
| ParallelCheck.sql | SET max_parallel_workers_per_gather = 4; | Parallel Plans |
| CTEvsSubquery.sql | EXPLAIN ANALYZE | Subquery vs. CTE |
| explain_scan_types.sql | EXPLAIN SELECT * FROM orders o JOIN customers c ON o.customer_id = c.id WHERE c.... | Reading EXPLAIN Plans |
| explain_analyze_example.sql | EXPLAIN (ANALYZE, BUFFERS) SELECT * FROM orders WHERE status = 'pending'; | EXPLAIN ANALYZE |
| visual_explain_usage.sql | EXPLAIN (ANALYZE, COSTS, VERBOSE, BUFFERS, FORMAT JSON) SELECT * FROM orders WHE... | Visual Explain Tools |
Key takeaways
Interview Questions on This Topic
How do you read a PostgreSQL execution plan — where do you start?
Frequently Asked Questions
20+ years shipping high-throughput database systems. Written from production experience, not tutorials.
That's SQL Advanced. Mark it forged?
8 min read · try the examples if you haven't