Hook: Interviewers love this question because a slow query is often not a SQL problem at all — it is a bad route chosen by the optimizer.
Question: How do you analyze an execution plan in SQL?
Answer: An execution plan is the database’s step-by-step strategy for running your query. I read it to see whether the engine is scanning whole tables, using indexes, choosing a sensible join order, and whether the estimated row counts are close to the real ones. If the estimates are far off, or I see expensive scans and joins, that usually points to missing indexes, stale statistics, or a query that needs rewriting.
Interview-Ready Answer: I start with EXPLAIN, and for real performance debugging I use EXPLAIN ANALYZE so I can compare estimated rows and cost against actual time and actual rows. Then I look for red flags like sequential scans on large tables, big estimate errors, nested loops over many rows, and filters that happen after reading the data instead of inside an index condition. My goal is to make the predicate sargable — search-argument-able, meaning the database can use an index directly — and to verify that the plan matches the data shape.
An execution plan is the optimizer’s recipe for answering your query. In most databases, the plan is a tree: leaf nodes read rows from tables or indexes, and parent nodes join, filter, sort, or aggregate those rows.
One important detail: cost is not time. It is an internal score the optimizer uses to compare choices. Two plans with costs 100 and 200 are only meaningful relative to each other on the same system, with the same statistics and settings.
random_page_cost and work_mem affect the choice.EXPLAIN ANALYZE, the engine also measures real time, actual rows, loops, and sometimes buffer reads.actual rows vs estimated rows: A big gap usually means the statistics are wrong or the predicate is misleading the optimizer.loops: This tells you how many times a node ran. A nested loop with 1,000 outer rows may execute the inner node 1,000 times.Filter vs Index Cond: Index Cond means the index helped narrow the search early. Filter means rows were removed after they were already read.BUFFERS: This helps separate CPU work from I/O. Many physical reads can make a query slow even when the time on one test looks okay.| Node | Meaning | Best for | Watch out |
|---|---|---|---|
| Seq Scan | Reads all rows | Small tables | Expensive on big tables |
| Index Scan | Uses index then table | Selective filters | Random I/O |
| Index Only Scan | Reads index only | Covered queries | Needs visibility checks |
| Bitmap Heap Scan | Batch index hits | Many matching rows | Extra setup cost |
For joins, the same idea applies: Nested Loop is good when one side is tiny; Hash Join is usually strong for large equality joins; Merge Join shines when both inputs are already sorted. If a hash table does not fit in work_mem — 4 MB by default in PostgreSQL — it can spill to disk and get slower.
Use plan analysis when a query slows down, after adding or changing an index, after bulk loads, or when data distribution shifts. It is the fastest way to decide whether the fix is the SQL text, the index design, or stale statistics.
DATE(created_at) is often worse than a range predicate on created_at.ANALYZE or autovacuum refreshes the stats.EXPLAIN ANALYZE on UPDATE, DELETE, or INSERT actually runs the change. Use a transaction and roll back if you are testing.random_page_cost and work_mem, can produce different plans. Do not compare plan text blindly across systems.Real-World Story: Imagine a checkout service for an e-commerce site. The team has a page that looks up recent orders by customer and status. After a nightly bulk import, the page suddenly jumps from 40 ms to 6 seconds. The plan shows a sequential scan on a table with millions of rows, and the estimate is wildly wrong: the optimizer thinks only a handful of rows will match, but thousands do.
The symptom is easy to miss at first because the query still works, but users feel spinning loaders, timeout errors, and occasional checkout delays. In the logs you might see repeated slow-query warnings, high CPU, and plan output like actual rows far above the estimate. The fix is usually boring but effective: refresh statistics with ANALYZE, add a composite index that matches the real filter order, and rewrite predicates so the planner can use the index instead of scanning everything.
The big lesson: execution plan analysis turns a vague complaint like ‘the app feels slow’ into a concrete diagnosis like ‘the optimizer chose the wrong access path because the data shape changed.’
-- PostgreSQL demo: read the plan, spot the bad access path, then fix it.
-- This script is safe to run in a scratch database or session.
DROP TABLE IF EXISTS orders_demo;
CREATE TEMP TABLE orders_demo (
order_id bigserial PRIMARY KEY,
customer_id int NOT NULL,
email text NOT NULL,
status text NOT NULL,
created_at timestamp NOT NULL
);
-- Build enough rows to make the optimizer care about access paths.
INSERT INTO orders_demo (customer_id, email, status, created_at)
SELECT
(g % 1000) + 1,
'user' || ((g % 1000) + 1) || '@example.com',
CASE
WHEN g % 3 = 0 THEN 'new'
WHEN g % 3 = 1 THEN 'paid'
ELSE 'shipped'
END,
now() - (g * interval '1 minute')
FROM generate_series(1, 20000) AS g;
ANALYZE orders_demo;
-- Good case: the filter is selective and matches a simple index.
CREATE INDEX idx_orders_demo_customer_id ON orders_demo (customer_id);
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders_demo
WHERE customer_id = 42;
-- Failure path: wrapping the column in a function makes the predicate non-sargable.
-- A normal index on email cannot help here because the planner must evaluate lower(email) row by row.
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders_demo
WHERE lower(email) = 'user42@example.com';
-- Fix: create a matching expression index so the planner can use the same expression efficiently.
CREATE INDEX idx_orders_demo_lower_email ON orders_demo (lower(email));
EXPLAIN (ANALYZE, BUFFERS)
SELECT *
FROM orders_demo
WHERE lower(email) = 'user42@example.com';
-- Note: if you test UPDATE/DELETE with EXPLAIN ANALYZE, wrap it in a transaction and ROLLBACK,
-- because EXPLAIN ANALYZE actually executes the statement.Follow-up & Tricky Questions:
EXPLAIN (ANALYZE, BUFFERS) add? ANALYZE runs the query and reports real timing, while BUFFERS shows whether the query used cached pages or had to read from disk. That makes it much easier to tell CPU issues from storage issues.(customer_id, created_at) can help a query that filters by customer_id and then sorts or filters by created_at. If you reverse the order, the same query may no longer use the index well.Nested Loop, Hash Join, and Merge Join. Each one signals a different trade-off between row counts, sort order, and memory use.Tricky / Gotchas:
EXPLAIN ANALYZE on an UPDATE just simulate the write? No. It actually executes the statement, so use a transaction and roll it back if you are experimenting.Index Only Scan still touch the table? Yes. If the visibility map is not ready, PostgreSQL may still fetch heap pages to verify that the row is visible.Common Mistakes:
Memory Hook: Think of an execution plan like a GPS route. The database can take the highway, the local road, or a scenic detour; EXPLAIN tells you which route it chose, and EXPLAIN ANALYZE tells you whether that route was actually fast.
Cheat Sheet:
EXPLAIN shows the chosen plan; EXPLAIN ANALYZE shows the plan plus real execution numbers.Filter means late filtering; Index Cond means early index use.Practice Tasks:
EXPLAIN on a simple query and identify the scan type.DATE(created_at) into a range filter and see how the plan changes.