Skip to main content

EXPLAIN ANALYZE visualizer

Understand your PostgreSQL query execution plans with AI-powered analysis

For people who have a slow query or an EXPLAIN ANALYZE output and want to understand what PostgreSQL is doing and how to make it faster.

About this tool

Understanding PostgreSQL query execution plans is one of the most valuable skills a database practitioner can develop, and also one of the most challenging. The output of EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) contains a wealth of information — node types, actual vs. estimated row counts, buffer usage, I/O timing, and loop counts — but interpreting it requires deep familiarity with the PostgreSQL query planner.

This tool analyzes your EXPLAIN ANALYZE output using AI trained on PostgreSQL internals. It identifies the most expensive nodes in your plan, detects common problems like poor row estimates, excessive heap fetches, or sort spilling to disk, and suggests concrete steps to improve performance — whether that means adding an index, rewriting a subquery, adjusting planner parameters, or reconsidering your schema.

Unlike simple plan visualizers that only show a tree view, this tool provides expert-level interpretation. It explains *why* PostgreSQL chose a particular plan, what assumptions the planner made, and what you can do to guide it toward a better path. It also flags version-specific behaviors, so you get advice relevant to your actual PostgreSQL version.

For best results, always run EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) rather than plain EXPLAIN. The actual execution statistics and buffer information are critical for accurate analysis. If you can, also enable track_io_timing so the tool can distinguish between CPU-bound and I/O-bound operations.

Examples

EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
select
o.order_id,
o.order_date,
c.customer_name,
sum(oi.quantity * oi.unit_price) as total
from orders o
join customers c on c.customer_id = o.customer_id
join order_items oi on oi.order_id = o.order_id
where o.order_date >= '2024-01-01'
and o.status = 'completed'
group by o.order_id, o.order_date, c.customer_name
order by total desc
limit 100;

A typical query joining three tables with filtering, aggregation, and sorting — the kind of query where EXPLAIN ANALYZE reveals whether indexes are used effectively and where time is actually spent.

Limit  (cost=15234.56..15234.81 rows=100 width=52) (actual time=892.451..892.487 rows=100 loops=1)
Buffers: shared hit=8234 read=4521
-> Sort (cost=15234.56..15334.56 rows=40000 width=52) (actual time=892.449..892.471 rows=100 loops=1)
Sort Key: (sum((oi.quantity * oi.unit_price))) DESC
Sort Method: top-N heapsort Memory: 37kB
Buffers: shared hit=8234 read=4521
-> HashAggregate (cost=14012.34..14412.34 rows=40000 width=52) (actual time=845.231..871.892 rows=38472 loops=1)
Group Key: o.order_id, o.order_date, c.customer_name
Batches: 1 Memory Usage: 6545kB
Buffers: shared hit=8234 read=4521
-> Hash Join (cost=3456.78..12512.34 rows=40000 width=30) (actual time=123.456..678.901 rows=156789 loops=1)
Hash Cond: (oi.order_id = o.order_id)
Buffers: shared hit=6234 read=4521
-> Seq Scan on order_items oi (cost=0.00..7890.12 rows=500000 width=16) (actual time=0.012..234.567 rows=500000 loops=1)
Buffers: shared hit=4321 read=2345
-> Hash (cost=2345.67..2345.67 rows=40000 width=26) (actual time=123.234..123.234 rows=38472 loops=1)
Buckets: 65536 Batches: 1 Memory Usage: 2890kB
Buffers: shared hit=1913 read=2176
-> Hash Join (cost=567.89..2345.67 rows=40000 width=26) (actual time=12.345..98.765 rows=38472 loops=1)
Hash Cond: (o.customer_id = c.customer_id)
Buffers: shared hit=1913 read=2176
-> Seq Scan on orders o (cost=0.00..1678.90 rows=40000 width=18) (actual time=0.023..67.890 rows=38472 loops=1)
Filter: ((order_date >= '2024-01-01'::date) AND (status = 'completed'))
Rows Removed by Filter: 61528
Buffers: shared hit=913 read=2176
-> Hash (cost=345.67..345.67 rows=10000 width=20) (actual time=12.123..12.123 rows=10000 loops=1)
Buckets: 16384 Batches: 1 Memory Usage: 597kB
Buffers: shared hit=1000
-> Seq Scan on customers c (cost=0.00..345.67 rows=10000 width=20) (actual time=0.008..6.789 rows=10000 loops=1)
Buffers: shared hit=1000
Planning Time: 1.234 ms
Execution Time: 893.012 ms

Sample EXPLAIN ANALYZE output showing the full plan tree with buffer statistics. The AI will identify that the sequential scan on order_items is the largest cost center and suggest a partial index on orders(order_date, status) to reduce the filter overhead.

Inputs and outputs

What you provide

  • EXPLAIN ANALYZE output (preferably with BUFFERS)
  • The SQL query being analyzed
  • PostgreSQL version (if not evident from the plan)

What you get

  • Plan tree analysis with bottleneck identification
  • Specific optimization recommendations with executable SQL
  • Prioritized action list

Use cases

  • Diagnosing slow queries by identifying the most expensive plan nodes and their root causes
  • Validating that a new index is being used by the planner and measuring the actual performance improvement
  • Understanding why the planner chose a sequential scan over an index scan (or vice versa) for a specific query
  • Detecting row estimation errors that cause the planner to choose suboptimal join strategies or sort methods
  • Comparing execution plans before and after a PostgreSQL major version upgrade to catch planner regressions

Features

  • Identifies the top bottleneck nodes by actual time and buffer usage
  • Detects estimation errors (actual rows vs. planned rows) and suggests statistics improvements
  • Recommends specific indexes based on filter conditions, join keys, and sort operations
  • Explains PostgreSQL-specific node types (BitmapAnd, Memoize, Incremental Sort) in plain language
  • Flags version-specific behaviors and settings that affect plan choice

Frequently asked questions

What is the difference between EXPLAIN and EXPLAIN ANALYZE in PostgreSQL?

Plain EXPLAIN shows the *estimated* execution plan — the planner's prediction of what it will do, including estimated costs and row counts. It does not actually run the query. EXPLAIN ANALYZE runs the query and adds *actual* execution statistics: real time per node, actual row counts, loop counts, and (with BUFFERS) shared/local buffer hit and read counts. The actual numbers are essential for finding performance problems because estimated row counts can be wildly wrong when table statistics are stale or when complex expressions confuse the planner. Always prefer EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT) for diagnosis. Be careful with EXPLAIN ANALYZE on data-modifying statements (INSERT, UPDATE, DELETE) — wrap them in a transaction and ROLLBACK afterward so the changes are not committed.

Why does my query use a sequential scan even though I have an index?

PostgreSQL's cost-based planner may choose a sequential scan over an index scan for several legitimate reasons. First, if the query will return a large fraction of the table (typically more than 5–15%, depending on settings), sequential I/O is faster than random I/O from an index. Second, the table may be small enough that it fits in shared buffers, making a sequential scan nearly free. Third, the planner's statistics may be inaccurate — run ANALYZE on the table to update them. Fourth, the index may not match the query's filter conditions or sort order closely enough. Fifth, random_page_cost (default 4.0) may be too high for your storage — on SSDs, setting it to 1.1–1.5 encourages index usage. You can check the planner's reasoning by comparing the estimated cost of a sequential scan vs. an index scan using SET enable_seqscan = off temporarily, but never leave this disabled in production.

How do I read buffer statistics in EXPLAIN ANALYZE output?

BUFFERS shows I/O statistics per plan node. shared hit means the page was already in PostgreSQL's shared buffer cache — this is fast. shared read means the page had to be fetched from the OS page cache or disk — this is slower. shared dirtied means the node modified a page, and shared written means it flushed a dirty page. For each node, the buffer counts are cumulative, including all child nodes. To isolate a single node's I/O, subtract its children's counts. High read counts on a node that processes few rows usually indicate poor index selectivity or bloated tables. If read is high but execution time is low, the OS page cache is serving the reads — still worth optimizing because under memory pressure those cache hits will become disk reads. Enable track_io_timing = on in postgresql.conf to see the actual time spent on I/O (shown as I/O Timings: read=... write=...), which distinguishes CPU-bound from I/O-bound queries.

What does "Rows Removed by Filter" mean and is it a problem?

When a plan node shows Rows Removed by Filter: N, it means PostgreSQL fetched N rows from the underlying data source (table or index) but then discarded them because they did not match the WHERE clause. A high number relative to the actual rows returned is a strong signal that the scan is reading far more data than necessary. For sequential scans, this often means a missing index — adding one on the filtered columns can eliminate most of the unnecessary reads. For index scans, it can mean the index covers only part of the filter condition; a composite index matching all conditions would be more selective. It can also appear after a Bitmap Heap Scan when the bitmap is "lossy" (block-level rather than row-level granularity), which happens when work_mem is too small to hold an exact bitmap. Increasing work_mem for the session or rewriting the query to reduce the bitmap size can help.

How can I get the most useful output from EXPLAIN ANALYZE?

Use the full form: EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT). Enable track_io_timing = on in your session (SET track_io_timing = on;) to get I/O timing breakdowns. For PostgreSQL 13+, add WAL to see WAL generation per node for write queries. Use FORMAT TEXT rather than FORMAT JSON when sharing with humans or this tool — TEXT format is more compact and easier to read. If the query is a SELECT, just run it. If it is an INSERT, UPDATE, or DELETE, wrap it in BEGIN; EXPLAIN (ANALYZE, BUFFERS) ...; ROLLBACK; to avoid side effects. Run the query twice — the first run warms the cache, the second gives you a representative "warm cache" plan. For the most representative results, run on a database with production-like data volume and statistics; plans on small dev databases often differ dramatically from production.

Related tools

Related resources

Ready to try it?

Use this tool for free — powered by PostgresAI.