Analyze Query Plans with EXPLAIN
Learn to use PostgreSQL's EXPLAIN command to analyze query plans, decode planner output, and optimize SQL performance.
Focus: use explain to analyze query plans
Your database was fine yesterday. Today, the same query that used to return in 50ms now takes 4 seconds. The table has 2 million rows, the indexes look right, and you've tried rewriting the query three times — nothing helps. You're not alone: most PostgreSQL performance problems are not bugs, they're plans. The database's query planner makes decisions based on statistics, costs, and heuristics, and when those decisions go wrong, no amount of guessing will fix it. This lesson gives you the surgeon's scalpel: the EXPLAIN command. Once you learn to use EXPLAIN to analyze query plans, you'll stop treating slow queries as mysteries and start treating them as diagnostics — problems you can read and fix.
The problem this lesson solves
Every SQL query you run goes through a hidden pipeline: parse → rewrite → plan → execute. Most developers only see the final result set, never the plan. But the plan is the difference between a fast query and a slow one.
Consider a common scenario: you have a users table with an index on email, and you run:
SELECT * FROM users WHERE email = 'alice@example.com';
If the planner decides to scan the whole table (Seq Scan), that's 2 million rows read. If it uses the index (Index Scan), that's maybe 5 rows. Which one runs? The planner decides — and it sometimes picks wrong.
Why this hurts you now: - You can't fix a query you can't see. - Guessing which part is slow wastes hours. - Statistics are stale, indexes are missing, or the planner just chose a bad join order — but you'll never know without reading the plan.
This lesson gives you the tool to see the execution plan, understand its vocabulary, and know exactly what to correct.
Core concept / mental model
The best mental model: the query planner is a cost-based optimizer. It doesn't know your data; it only knows statistics — row counts, distinct values, histogram buckets — gathered (or guessed) about your tables. For each possible plan, it estimates a cost (an arbitrary unit representing I/O and CPU). It then picks the plan with the lowest estimated cost.
EXPLAIN shows you that plan, along with the cost estimates and the actual execution statistics (when you use ANALYZE). It's like a flight recorder: it tells you what the pilot (planner) decided and why.
Key terms to load into your working vocabulary:
| Term | Meaning | Example |
|---|---|---|
| Seq Scan | Full table scan — reads every row. Usually bad for filtered lookups on big tables. | Seq Scan on users |
| Index Scan | Uses an index to find matching rows, then fetches the table rows. | Index Scan using users_email_idx |
| Index Only Scan | All needed columns are in the index — no table lookup needed. Best case for read-heavy queries. | Index Only Scan using users_email_idx |
| Bitmap Heap Scan | Combines index matches into a bitmap, then reads rows from the heap in physical order. Good when many rows match. | Bitmap Heap Scan on orders |
| Nested Loop | For each row in the outer relation, search the inner relation with an index. Best for small outer sets. | Nested Loop (inner: Index Scan) |
| Hash Join | Builds a hash table on one side, probes with the other. Good for large, unsorted inputs. | Hash Join (hashcond: ...) |
| Merge Join | Sorts both inputs and merges. Best for already-sorted data. | Merge Join (mergecond: ...) |
| Sort | Explicit sort step — often a sign a more efficient order could be used. | Sort (sortkey: created_at) |
| Aggregate | Combination across rows (GROUP BY, DISTINCT, etc.). | HashAggregate (group key: user_id) |
Pro tip: The plan is read inside out. Start at the deepest, most indented node — that's the first thing executed. Work your way up to the top node, which is the final output.
How it works step by step
What happens when you run EXPLAIN?
- You write
EXPLAINbefore your query. The planner generates a plan (without executing it) and returns a textual representation of that plan as rows. - The output shows nodes with cost, rows, and width estimates. For each node, you'll see something like
cost=0.00..35.50 rows=10 width=244. Thecostis an estimated number of arbitrary units — the0.00is startup cost,35.50is total cost.rowsis the estimated output rows.widthis the estimated average row size in bytes. EXPLAIN ANALYZEactually runs the query. It adds actual timings (actual time=...) and row counts. This shows you the estimated vs actual gap — the most important diagnostic signal.- You compare estimates to actuals. If
rows=10estimated butactual rows=10000, the planner's statistics are off — likely missing or stale stats. - You interpret the node types and costs to find the bottleneck (see next section).
Reading order rule: Start at the innermost node. That's the first operation. Each parent uses the output of its children. The top node is the final result.
Simple example plan:
EXPLAIN SELECT * FROM users WHERE id = 42;
Output (simplified):
Index Scan using users_pkey on users (cost=0.28..8.29 rows=1 width=514)
Index Cond: (id = 42)
Reading: The planner chose an Index Scan on the primary key, estimated a single row, and the condition is id = 42. Good plan.
Hands-on walkthrough
Let's build a realistic scenario and analyze it end-to-end.
Setup: create sample data
-- Create a table and an index
CREATE TABLE orders (
id SERIAL PRIMARY KEY,
user_id INT NOT NULL,
total NUMERIC(10,2) NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Insert 1 million rows (takes a few seconds)
INSERT INTO orders (user_id, total)
SELECT (random() * 10000)::int, (random() * 1000)::numeric(10,2)
FROM generate_series(1, 1000000);
-- Create an index on user_id
CREATE INDEX idx_orders_user_id ON orders(user_id);
-- Refresh statistics
ANALYZE orders;
Step 1: Run EXPLAIN without ANALYZE
EXPLAIN SELECT * FROM orders WHERE user_id = 500;
Output (your costs may differ slightly):
Bitmap Heap Scan on orders (cost=62.37..1457.53 rows=989 width=24)
Recheck Cond: (user_id = 500)
-> Bitmap Index Scan on idx_orders_user_id (cost=0.00..62.12 rows=989 width=0)
Index Cond: (user_id = 500)
What you see: A two-step plan. First the index finds matching row locations (Bitmp Index Scan), then reads the actual data rows (Bitmap Heap Scan). The planner expects ~989 rows (since user_id ranges from 0 to 10000, each appears ~100 times).
Step 2: Run EXPLAIN ANALYZE to get real numbers
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 500;
Output:
Bitmap Heap Scan on orders (cost=62.37..1457.53 rows=989 width=24) (actual time=0.019..0.635 rows=1053 loops=1)
Recheck Cond: (user_id = 500)
Heap Blocks: exact=108
-> Bitmap Index Scan on idx_orders_user_id (cost=0.00..62.12 rows=989 width=24) (actual time=0.025..0.025 rows=1053 loops=1)
Index Cond: (user_id = 500)
Planning Time: 0.098 ms
Execution Time: 0.669 ms
Compare: Estimated rows=989, actual rows=1053 — close enough (within 7%). The plan is good. Execution Time: 0.669 ms is fast. No problem here.
Step 3: Simulate a bad plan
Remove the index and re-run:
DROP INDEX idx_orders_user_id;
EXPLAIN ANALYZE SELECT * FROM orders WHERE user_id = 500;
Output:
Seq Scan on orders (cost=0.00..19853.20 rows=1082 width=24) (actual time=0.012..156.094 rows=1082 loops=1)
Filter: (user_id = 500)
Rows Removed by Filter: 998918
Planning Time: 0.069 ms
Execution Time: 156.213 ms
Now the planner chose a Seq Scan — reading all 1 million rows, filtering out 998,918. The execution time jumped to 156 ms — 233 times slower than the indexed plan. The error is obvious: there's no index. The fix is to re-create the index.
Step 4: Use EXPLAIN to compare a join
-- Create a small users table
CREATE TABLE users (id INT PRIMARY KEY, name TEXT);
INSERT INTO users SELECT id, 'user_' || id FROM generate_series(1, 10000) id;
EXPLAIN ANALYZE
SELECT u.name, SUM(o.total) AS total_spent
FROM users u
JOIN orders o ON o.user_id = u.id
WHERE u.id = 500
GROUP BY u.name;
Output (partial):
GroupAggregate (cost=0.42..1056.98 rows=1 width=40) (actual time=0.154..0.344 rows=1 loops=1)
-> Nested Loop (cost=0.42..1056.98 rows=1053 width=40) (actual time=0.144..0.319 rows=1053 loops=1)
-> Index Scan using users_pkey on users u (cost=0.28..8.29 rows=1 width=22) (actual time=0.018..0.020 rows=1 loops=1)
Index Cond: (id = 500)
-> Bitmap Heap Scan on orders o (cost=0.14..1042.18 rows=1053 width=18) (actual time=0.124..0.239 rows=1053 loops=1)
Recheck Cond: (user_id = 500)
-> Bitmap Index Scan on idx_orders_user_id (cost=0.00..0.14 rows=1053 width=0) (actual time=0.118..0.118 rows=1053 loops=1)
Index Cond: (user_id = 500)
Planning Time: 0.120 ms
Execution Time: 0.364 ms
The join uses a Nested Loop: for the single user, it looks up their orders via the index. The estimates match actuals well. Execution is fast.
Pro tip: If the planner picks a Nested Loop when the outer table has 100k rows, that's a red flag — each row triggers a lookup, leading to 100k index probes. Other join types like Hash Join would've been better.
Compare options / when to choose what
You have several ways to get plan information, each with its own use case:
| Command | Executes query? | Shows actual timings? | Best for |
|---|---|---|---|
EXPLAIN |
No | No | Quick plan structure, cost estimates without side effects |
EXPLAIN ANALYZE |
Yes | Yes | Real execution timings; verify estimates vs actuals |
EXPLAIN (ANALYZE, BUFFERS) |
Yes | Yes + buffer usage | Diagnose I/O; see how many blocks were read from disk vs cache |
EXPLAIN (ANALYZE, COSTS OFF) |
Yes | Yes, costs omitted | Read plan readability when numbers overwhelm |
EXPLAIN (ANALYZE, FORMAT JSON) |
Yes | Yes | Programmatic parsing (e.g., in tools) |
EXPLAIN (ANALYZE, SUMMARY OFF) |
Yes | Yes | Hide summary lines when you want fewer rows |
When to choose what:
- Use plain EXPLAIN to get a fast read of the plan and spot obvious issues (seq scans on big tables).
- Use EXPLAIN ANALYZE when you need to confirm actual performance — especially when a query is slow.
- Add BUFFERS when you suspect I/O — it shows whether data came from cache (shared hit) or disk (shared read).
- Use FORMAT JSON when you want to parse plans programmatically (e.g., in a monitoring script).
- Use COSTS OFF is a handy readability tweak but doesn't change the plan.
Alternatives to EXPLAIN:
- auto_explain module: logs query plans automatically after a configurable threshold. Great for production — turn it on and catch slow queries as they happen.
- pg_stat_statements: tracks query execution statistics — helps identify frequently slow queries, but doesn't show plans.
- Visual tools like pgAdmin's graphical explain or pev2 (Postgres Explain Visualizer) render plans as trees — helpful for beginners.
When to use which: For a one-off slow query, EXPLAIN ANALYZE is enough. For ongoing performance issues, set up auto_explain or a monitoring tool.
Troubleshooting & edge cases
1. You can't run EXPLAIN ANALYZE on an INSERT, UPDATE, or DELETE that you don't want to execute.
Solution: Use EXPLAIN (without ANALYZE) to see the modification plan (which nodes are used for scanning), or wrap the statement in a transaction and ROLLBACK:
BEGIN;
EXPLAIN ANALYZE DELETE FROM orders WHERE total < 0;
ROLLBACK;
2. Slow query but EXPLAIN shows a good plan.
This happens when actual execution differs due to caching. Run EXPLAIN (ANALYZE, BUFFERS) to see if disk reads dominate. If the slow query is in production with cold cache, the plan may be fine — the bottleneck is I/O, not the planner.
3. Estimates are wildly off (rows=100 but actual rows=100000).
Cause: stale statistics. Fix: run ANALYZE on the table (or VACUUM ANALYZE). With extended statistics, you may need CREATE STATISTICS for correlated columns.
4. Planner keeps choosing a Seq Scan even though an index exists.
The planner might think a Seq Scan is cheaper because:
- The table is very small.
- The condition matches a large percentage of rows (more than ~5-10%).
- The index isn't the right one for the condition (e.g., function on column WHERE lower(email) = ... won't use a plain index).
Use SET enable_seqscan = off; to see the planner's alternate plan — but only for analysis, not in production.
5. The Sort node is too expensive.
Solution: Add an index that matches the sort order (e.g., CREATE INDEX ON orders (created_at)) so the planner can use an Index Scan to avoid sorting.
6. Nested Loop with hundreds of loops.
Watch for loops=10000 in the inner node. That indicates a poor join strategy. Check whether the planner has correct statistics on the join columns; ANALYZE often helps.
7. EXPLAIN ANALYZE is slower than your actual query.
This happens because it runs the query (and any side effects). For large UPDATEs, that's expected. Use plain EXPLAIN to check the plan without running.
What you learned & what's next
You now have the essential skill of use EXPLAIN to analyze query plans. Specifically, you learned:
- The problem: slow queries often stem from poor plans, not syntax errors.
- The mental model: the planner uses cost-based optimization with statistics.
- How it works: reading plans inside-out, comparing estimates to actuals.
- Hands-on: you ran EXPLAIN, EXPLAIN ANALYZE, and BUFFERS on real data, spotting a missing index and a join plan.
- Compare options: you know which EXPLAIN variants to use and when.
- Troubleshooting: you can diagnose stale stats, forced Seq Scans, and wrong join types.
Armed with this, you can now approach any slow query methodically. In the next lesson, we'll build on this foundation and explore indexing strategies — how to choose and maintain indexes that make the planner look good. You'll learn about B-tree, GIN, and partial indexes, and how to use EXPLAIN to validate their effectiveness.
Now go run EXPLAIN ANALYZE on your trickiest query and see what it's really doing.
Practice recap
Take the orders table from the hands-on section and run EXPLAIN ANALYZE on a query that joins orders and users with a GROUP BY. Drop the user_id index and rerun — note the plan change from Bitmap Heap Scan to Seq Scan. Then recreate the index and ANALYZE to see the improved execution time.
Common mistakes
- Forgetting the
ANALYZEkeyword — you get a plan without actual execution data, leaving estimates unverified, and you think the plan is good when it's actually slow. - Reading the plan top-to-bottom instead of inside-out, which misleads you about which operation runs first and costs the most
- Stopping after spot-checking the first node and missing deeper join nodes that cause the real performance hits
- Using
EXPLAIN ANALYZEon anINSERTorUPDATEwithout a transaction, causing unintended writes or deletions
Variations
- Use
EXPLAIN (ANALYZE, BUFFERS)to include I/O details — essential for diagnosing disk vs cache behavior - Try
EXPLAIN (FORMAT JSON)for machine-readable plans that you can parse in monitoring tools or visualize with pev2 - Set up
auto_explainto automatically log plans for slow queries in production, so you don't have to rerun them manually
Real-world use cases
- Tuning a periodically slow API endpoint by capturing the query plan for the main search query and identifying a missing index on a column used in the WHERE clause
- Diagnosing a nightly ETL job that runs out of memory by running
EXPLAIN ANALYZEon the largest join and finding a Hash Join with an oversized hash table - Comparing two query rewrite options for a dashboard with a multi-table aggregate — use
EXPLAINto see which runs with fewer sorts and lower total cost before committing
Key takeaways
EXPLAINshows the planner's execution plan without running the query;EXPLAIN ANALYZEruns it and reveals actual times and row counts- Read plans inside-out: the innermost node executes first, and each parent consumes its child's output
- The planner is cost-based; large gaps between estimated and actual rows point to stale statistics — fix with
ANALYZE - A
Seq Scanon a big table with a selective filter usually means a missing or unusable index - Use
BUFFERSto see whether I/O is the bottleneck, andauto_explainfor production diagnostics - The next step after understanding plans is to apply indexing strategies and validate them with
EXPLAIN
Keep learning
Related tutorials, quizzes, and articles for this topic.
Discussion
Questions, corrections, and tips help everyone reading this page.
0 comments
Add a comment
No comments yet — start the thread.