EXPLAIN ANALYZE

Learn how to use EXPLAIN ANALYZE in PostgreSQL to execute a query and capture real timing, actual row counts, and buffer statistics for precise performance diagnosis.

5 min read · Last updated: March 2026 · Back to overview

Quick Answer

EXPLAIN ANALYZE executes the query and adds actual runtime statistics—real execution time per node and actual row counts—alongside the planner's estimates. Use EXPLAIN (ANALYZE, BUFFERS) to also see shared buffer hits and disk reads, which shows whether a query is I/O-bound.

Spin up a Postgres database in 20 seconds with Vela.

Try Vela Sandbox

EXPLAIN ANALYZE is the most powerful query-tuning tool in PostgreSQL. Unlike plain EXPLAIN, it actually runs the query and reports real execution time and row counts at every node in the plan tree. Comparing the "actual" numbers against the planner's "estimated" numbers quickly reveals where the optimizer is making wrong assumptions.

Basic EXPLAIN ANALYZE usage

EXPLAIN ANALYZE
SELECT f.film_id, f.title, c.name AS category_name
FROM film f
JOIN film_category fc ON fc.film_id = f.film_id
JOIN category c       ON c.category_id = fc.category_id
ORDER BY f.title;

Each node shows both estimated and actual statistics:

 Sort  (cost=295.65..298.15 rows=1000 width=87)
       (actual time=12.301..12.549 rows=1000 loops=1)
   Sort Key: f.title
   Sort Method: quicksort  Memory: 167kB
   ->  Hash Join  (cost=45.50..245.50 rows=1000 width=87)
                  (actual time=0.934..11.102 rows=1000 loops=1)
         Hash Cond: (fc.category_id = c.category_id)
 Planning Time: 0.512 ms
 Execution Time: 12.841 ms

Key fields to examine:

  • actual time=X..Y: milliseconds from node startup (X) to last row returned (Y).
  • rows=N: actual row count vs. the planner's estimate—large gaps indicate stale statistics.
  • loops=N: how many times the node executed; multiply actual time by loops for total cost of that node.

Adding BUFFERS for I/O analysis

EXPLAIN (ANALYZE, BUFFERS)
SELECT customer_id, SUM(total_amount) AS total
FROM orders
WHERE placed_at >= now() - interval '30 days'
GROUP BY customer_id
ORDER BY total DESC
LIMIT 10;

With BUFFERS, each node reports:

   Buffers: shared hit=520 read=38

"Shared hit" means the data was found in the shared buffer cache. "Read" means PostgreSQL had to fetch pages from disk. A high "read" count relative to "hit" means the working set does not fit in cache—adding memory or an index can help.

Analyzing write statements safely

Because EXPLAIN ANALYZE executes the statement, always wrap DML inside a transaction when you want to measure without committing changes:

BEGIN;
EXPLAIN ANALYZE
UPDATE orders
SET status = 'archived'
WHERE placed_at < now() - interval '2 years';
ROLLBACK;  -- no rows are actually changed

Diagnosing row-count misestimates

When actual rows differ sharply from the estimate, the planner is working with stale or inaccurate statistics. Fix by running ANALYZE on the table:

ANALYZE orders;

-- Then re-run EXPLAIN ANALYZE to see updated estimates
EXPLAIN ANALYZE SELECT * FROM orders WHERE status = 'pending';

For highly skewed data distributions, increase the statistics target for the relevant column:

ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;

Practical tips

  • Run EXPLAIN ANALYZE at least twice and compare results—the first run may include cold cache effects.
  • A "loops" value greater than 1 in a nested-loop join multiplies the node cost; an index on the inner table's join key typically eliminates this.
  • Use FORMAT JSON with a visualization tool like explain.depesz.com to navigate complex plans visually.
  • The "Planning Time" at the bottom is the time spent choosing the plan; very high planning times on complex queries can sometimes be reduced by simplifying CTEs or using enable_* configuration to restrict plan choices.

Continue in Performance: Indexes and Index-Only Scans.

Related in this section: EXPLAIN · Indexes and Index-Only Scans · ANALYZE Command

Frequently Asked Questions

What is EXPLAIN ANALYZE in PostgreSQL?

EXPLAIN ANALYZE executes the SQL statement and augments the normal EXPLAIN output with real execution time (in milliseconds) and actual row counts at each node. This makes it possible to compare planner estimates against real behavior and identify bottlenecks.

What does "loops=N" mean in EXPLAIN ANALYZE output?

A node with loops=N ran N times. This is typical for the inner side of a nested loop join—it executes once per outer row. The actual time shown is per loop, so multiply by loops to get total time for that node.

How do I use EXPLAIN ANALYZE without modifying data?

Wrap the DML in a transaction: BEGIN; EXPLAIN ANALYZE your_dml; ROLLBACK; PostgreSQL executes the statement (required to collect actual statistics) but the ROLLBACK ensures no changes are committed.

What does EXPLAIN (ANALYZE, BUFFERS) add over plain EXPLAIN ANALYZE?

The BUFFERS option adds shared buffer hit/read/written counts and (when applicable) local and temporary buffer statistics to each node. This shows whether the query is reading data from cache (fast) or disk (slow), which guides decisions about increasing shared_buffers or adding indexes.

Why do actual rows differ from estimated rows in EXPLAIN ANALYZE?

The planner uses table statistics (collected by ANALYZE) to estimate row counts. Stale statistics, highly correlated columns, or skewed data distributions cause misestimates. Run ANALYZE on the table and consider increasing the statistics target for important columns with ALTER TABLE ... ALTER COLUMN ... SET STATISTICS N.