EXPLAIN ANALYZE Query Plans
Learn to analyze PostgreSQL query plans with EXPLAIN ANALYZE — hands-on steps, troubleshooting, and what's next.
Focus: analyze query plans with explain analyze
Ever stared at a PostgreSQL query that runs fine on your laptop but crawls in production — and had no idea why? You've added indexes, rewritten joins, maybe even prayed to the database gods. The truth is, you've been flying blind. PostgreSQL has a built-in oracle — EXPLAIN ANALYZE — that shows you exactly how every query executes, where the time goes, and which part of your plan is the real bottleneck. Without it, you're guessing. With it, you turn query optimization from black magic into a systematic, evidence-based skill. This lesson gives you the mental model and hands-on practice to analyze query plans with EXPLAIN ANALYZE and start making your queries faster today.
The Problem This Lesson Solves
Slow queries are the silent killers of application performance. They don't crash your app; they just make it gradually more frustrating — pages hang, API timeouts pile up, and your database CPU spikes for no obvious reason. The worst part? Without a way to see what PostgreSQL actually does, you end up guessing: adding random indexes, rewriting queries hoping for the best, or just throwing hardware at the problem.
The cost of guessing: You waste hours, add complexity, and often make things worse. An index on the wrong column can slow down writes. A rewrite that looks cleaner might force PostgreSQL into a worse join strategy.
EXPLAIN ANALYZE gives you a diagnostic report from the database itself. It tells you:
- Which indexes were actually used (and which were ignored)
- How many rows were scanned vs. returned
- How long each step actually took
- Where the time is really spent (often a surprise)
Pro tip: If you've never run
EXPLAIN ANALYZEon a slow query, you're not optimizing — you're guessing.
By the end of this lesson, you'll be able to read a query plan like a mechanic reads a diagnostic code — and fix the root cause, not the symptom.
Core Concept / Mental Model
Think of EXPLAIN ANALYZE as a flight recorder for your SQL query. When you run it, PostgreSQL actually executes the query (that's the ANALYZE part) and logs every step of the journey, from the first table scan to the final result.
The output is a tree of nodes, each representing a step in the execution. Each node shows:
- Operation (e.g.,
Seq Scan,Index Scan,Hash Join) - Cost — a relative measure of effort (not milliseconds!)
- Rows — estimated vs. actual
- Time — actual execution time for that step
- Loops — how many times the step ran
A Simple Analogy
Imagine you're a restaurant manager trying to speed up order delivery. You could guess that the kitchen is slow, but you'd be wrong. So you put a stopwatch on every station: the waiter's trip to the table, the chef's cooking time, the plating, the runner's path. Now you know the bottleneck is the runner taking the long route.
EXPLAIN ANALYZE is that stopwatch — it measures every station in your query's execution pipeline.
Key Vocabulary
- Sequential Scan (
Seq Scan) — reads the entire table row by row. Fine for small tables, deadly for large ones. - Index Scan / Index Only Scan — uses an index to find rows quickly, like using a book's index vs. reading every page.
- Join Types —
Hash Join,Nested Loop,Merge Join: different strategies PostgreSQL uses to combine tables. - Cost — a unitless number; think of it as "estimated effort." Actual time matters more.
Note:
EXPLAINwithoutANALYZEonly shows estimates. AddANALYZEto get actual times — but be careful: it runs the query, so don't use it on write queries unless you're ready to commit (you can wrap in a transaction and roll back if needed).
How It Works Step by Step
When you run EXPLAIN ANALYZE, here's what happens behind the scenes:
- Parser and Planner — PostgreSQL parses your SQL and generates multiple candidate execution plans.
- Optimization — The planner estimates costs using table statistics (from
ANALYZE) and picks the cheapest plan. - Execution — PostgreSQL actually runs the query (because of
ANALYZE), measuring real rows and times. - Reporting — It prints the plan tree with both estimated costs and actual performance metrics.
Reading the Plan Tree
The output is indented; each indentation level is a sub-step. You read it from bottom to top — the innermost operation happens first, and results flow upward to the final result.
Hash Join (cost=1.14..3.25 rows=5 width=36) (actual time=0.045..0.059 rows=5 loops=1)
Hash Cond: (e.emp_id = d.emp_id)
-> Seq Scan on employees e (cost=0.00..1.05 rows=5 width=20) (actual time=0.010..0.012 rows=5 loops=1)
-> Hash (cost=1.01..1.01 rows=1 width=16) (actual time=0.018..0.019 rows=1 loops=1)
-> Seq Scan on departments d (cost=0.00..1.01 rows=1 width=16) (actual time=0.009..0.010 rows=1 loops=1)
Key columns:
actual time— the real execution time in milliseconds (often very different fromcost).rows— the actual number of rows processed; compare to the estimate to spot misestimates.loops— how many times that step executed; high loop counts can hide inefficiency.
Why ANALYZE Matters
The ANALYZE keyword is the difference between a guess and a measurement:
EXPLAINalone: shows the plan with estimated costs — no execution.EXPLAIN ANALYZE: executes the query and shows actual times and row counts.
This is crucial because the planner's estimates can be wildly off. When you see a huge gap between estimated and actual rows, that's a signal something's wrong — stale statistics, no vacuum, or a missing index.
Hands-On Walkthrough
Let's get our hands dirty. Start by setting up a small test database.
Setup
CREATE TABLE employees (id SERIAL PRIMARY KEY, name TEXT, department_id INT);
CREATE TABLE departments (id SERIAL PRIMARY KEY, name TEXT);
INSERT INTO employees (name, department_id) VALUES ('Alice', 1), ('Bob', 2), ('Charlie', 1);
INSERT INTO departments (name) VALUES ('Engineering'), ('Sales');
Example 1: Basic EXPLAIN ANALYZE
EXPLAIN ANALYZE SELECT * FROM employees WHERE department_id = 1;
Output (simplified):
Seq Scan on employees (cost=0.00..1.05 rows=2 width=36) (actual time=0.008..0.011 rows=2 loops=1)
Filter: (department_id = 1)
Rows Removed by Filter: 1
Planning Time: 0.082 ms
Execution Time: 0.025 ms
What you see: Seq Scan — a full table scan (fine for 3 rows). The Filter removes 1 row. Now let's add an index and see the change.
Example 2: Index Scan in Action
CREATE INDEX idx_employees_department ON employees(department_id);
EXPLAIN ANALYZE SELECT * FROM employees WHERE department_id = 1;
Output:
Index Scan using idx_employees_department on employees (cost=0.14..8.15 rows=2 width=36) (actual time=0.011..0.014 rows=2 loops=1)
Index Cond: (department_id = 1)
Planning Time: 0.101 ms
Execution Time: 0.020 ms
For small tables, the index doesn't help much — but in real-world data, it's night and day. The lesson: measure everything, don't assume.
Example 3: Finding a Real Bottleneck
Let's simulate a slow query with a cross join (a common mistake).
EXPLAIN ANALYZE SELECT * FROM employees e CROSS JOIN departments d WHERE e.id = d.id;
Output:
Nested Loop (cost=0.00..107.36 rows=9 width=72) (actual time=0.037..0.072 rows=3 loops=1)
-> Seq Scan on employees e (cost=0.00..1.05 rows=5 width=36) (actual time=0.009..0.011 rows=3 loops=1)
-> Materialize (cost=0.00..1.01 rows=5 width=36) (actual time=0.012..0.014 rows=3 loops=3)
-> Seq Scan on departments d (cost=0.00..1.01 rows=3 width=36) (actual time=0.006..0.008 rows=3 loops=1)
Notice Nested Loop and Materialize. The loops=3 on the departments scan hints at repetition. An index or join reorder could help.
Pro tip: When you see
loopsgreater than 1 on an expensive operation, drill into it. That's multiplying the cost.
Now try it yourself: Run EXPLAIN ANALYZE on a query that joins two large tables and see how long each step takes.
Compare Options / When to Choose What
EXPLAIN comes in several flavors. Here's a comparison:
| Command | What it does | Best used when |
|---|---|---|
EXPLAIN |
Shows the plan with estimated costs, doesn't execute | You want to check the plan without running the query |
EXPLAIN ANALYZE |
Executes the query and shows actual times & rows | You're troubleshooting performance and can run the query safely |
EXPLAIN (ANALYZE, BUFFERS) |
Also shows buffer hits/reads | You suspect I/O issues |
EXPLAIN (ANALYZE, FORMAT JSON) |
Outputs machine-readable JSON | You're building tooling or want a structured output |
Alternatives to EXPLAIN ANALYZE
EXPLAIN(without analyze) — faster, no execution, but less accurate.pg_stat_statements— tracks execution stats generic aggregated, good for spotting repeated slow queries in production.- Auto-explain extension — automatically logs plans for slow queries above a threshold — great for production monitoring.
But for a one-off deep dive, EXPLAIN ANALYZE is your first tool. It's available everywhere and gives you the ground truth.
Troubleshooting & Edge Cases
"My query ran for 10 seconds but EXPLAIN ANALYZE says 1ms"
You're probably looking at an estimated cost, not actual time. Or you ran EXPLAIN without ANALYZE. Always check for the actual time column.
"The planner uses a Seq Scan even though I created an index"
This can happen if:
- The table is small (full scan is faster)
- The statistics are stale — run
ANALYZE table;to update them - The index doesn't match the WHERE clause (e.g., you filtered on a column without an index)
"EXPLAIN ANALYZE modifies data?"
Yes — ANALYZE executes the query. For INSERT, UPDATE, DELETE, the changes are real. To inspect safely, wrap in a transaction and roll back:
BEGIN;
EXPLAIN ANALYZE UPDATE employees SET name = 'Zoe' WHERE id = 1;
ROLLBACK;
This shows you the plan but discards the change.
"My EXPLAIN ANALYZE output is huge"
On complex queries, the tree can be overwhelming. Work bottom-up: find the slowest node (largest actual time), look at its parent, and understand the data flow. Use FORMAT JSON if you're parsing it programmatically.
Pro tip: Focus on the top 1-2 expensive nodes. Fixing a single
Seq Scanon a large table usually solves the whole query.
What You Learned & What's Next
Congratulations! You now can analyze query plans with EXPLAIN ANALYZE — you understand the difference between estimates and actuals, you can read a plan tree, and you know how to spot common bottlenecks like full scans and misestimated row counts.
You've achieved the learning objectives:
- You can explain the core idea behind
EXPLAIN ANALYZE: it executes the query and reveals exactly where time is spent. - You've completed a practical exercise by running
EXPLAIN ANALYZEon sample queries and interpreting the output.
Next in the track, you'll build on this skill by learning how to optimize those slow spots — adding effective indexes, rewriting queries to use better join strategies, and using tools like pg_stat_statements to catch issues in production. You're no longer guessing; you're diagnosing.
Final tip: Make
EXPLAIN ANALYZEyour reflexive response to any slow query. It's the difference between a database firefighter and a database arsonist. Keep this tool sharp, and your queries will thank you.
Practice recap
Write a query on your employees table that joins two related tables. Run EXPLAIN ANALYZE and identify the most expensive step. Add an index you think will help, re-run the analysis, and compare the actual times. Try the same with EXPLAIN (ANALYZE, BUFFERS) to see I/O behavior.
Common mistakes
- Forgetting to include
ANALYZE— you get only estimates, not actuals, and might misdiagnose. - Running
EXPLAIN ANALYZEon write queries without a transaction, causing unintended changes. - Ignoring the
loopscolumn — a high loop count on a slightly slow node can multiply the total time significantly. - Relying on
costnumbers as if they were milliseconds — they're unitless estimates, not real times. - Dismissing a
Seq Scanas always bad — on small tables it's often the fastest option.
Variations
- Use
EXPLAIN (ANALYZE, BUFFERS)to measure disk vs. cache reads for I/O-bound queries. - Leverage
EXPLAIN (ANALYZE, FORMAT JSON)to parse the plan programmatically or view pictorially in tools like pgAdmin. - Enable the
auto_explainextension to log plans for slow queries automatically in production.
Real-world use cases
- Diagnosing an API endpoint that times out in production due to an unindexed foreign key query.
- Auditing a nightly batch job that runs for hours, using EXPLAIN ANALYZE to find a nested-loop join bottleneck.
- Pre-deployment review of a new complex report query to ensure it uses indexed scans and stays under performance budget.
Key takeaways
EXPLAIN ANALYZEexecutes the query and shows actual execution times and row counts, unlikeEXPLAINalone.- Read the plan from bottom-up: the innermost step runs first and feeds the next.
- Watch for the
actual timeandrowscolumns — big gaps from estimates signal stale stats or wrong assumptions. - A high
loopscount magnifies the cost of any node — investigate it. - Always use transactions to test write queries safely with
EXPLAIN ANALYZE. - Make
EXPLAIN ANALYZEyour default response to any slow query — it replaces guesswork with evidence.
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.