PostgreSQL

PostgreSQL EXPLAIN ANALYZE: Reading Query Plans and Fixing Slow Queries

EXPLAIN ANALYZE tells you exactly why your PostgreSQL query is slow, but reading its output is a skill most developers never learn. Learn every node type, identify the five worst plan patterns, and fix them.

JusDB Team
January 3, 2025
9 min read
143 views

Your PostgreSQL query takes 8 seconds in production but 40ms in staging on the same data. EXPLAIN ANALYZE tells you exactly why — but reading its output is a skill most developers never learn. Here's the complete guide to understanding every line and acting on what it tells you.

TL;DR
  • EXPLAIN (ANALYZE, BUFFERS) shows actual runtime, row counts, and buffer hits/misses
  • The biggest cost node in the plan is your starting point for optimization
  • Seq Scan on large tables almost always means a missing index
  • Nested Loop on large row sets signals a missing join index or bad statistics

The Right EXPLAIN Command

sql
-- Always use ANALYZE + BUFFERS in production investigations
EXPLAIN (ANALYZE, BUFFERS, FORMAT TEXT)
SELECT o.id, o.total, c.name
FROM orders o
JOIN customers c ON o.customer_id = c.id
WHERE o.status = 'pending'
  AND o.created_at > NOW() - INTERVAL '7 days';

-- ANALYZE: executes the query and shows actual vs estimated rows
-- BUFFERS: shows shared buffer hits vs disk reads (key for I/O diagnosis)
-- FORMAT TEXT: readable tree format (JSON is better for tooling)

-- WARNING: EXPLAIN ANALYZE actually runs the query
-- For destructive queries, wrap in a transaction and rollback:
BEGIN;
EXPLAIN ANALYZE DELETE FROM logs WHERE created_at < '2024-01-01';
ROLLBACK;

Reading the Plan Tree

bash
-- Example output:
Hash Join  (cost=1234.56..5678.90 rows=42 width=48)
           (actual time=12.345..234.567 rows=38 loops=1)
  Buffers: shared hit=1240 read=892
  ->  Seq Scan on orders  (cost=... rows=15000 ...)
        (actual time=0.012..198.123 rows=14823 loops=1)
        Filter: ((status = 'pending') AND (created_at > ...))
        Rows Removed by Filter: 985177
        Buffers: shared hit=200 read=888
  ->  Hash  (cost=823.00..823.00 rows=30000 ...)
        ->  Seq Scan on customers  ...

-- KEY NUMBERS:
-- cost=1234.56..5678.90  => estimated startup..total cost (planner units)
-- rows=42                => estimated output rows
-- actual time=12..234    => real ms: startup..total
-- actual rows=38         => real rows returned
-- loops=1                => how many times this node executed
-- Buffers shared hit=N   => pages served from cache (fast)
-- Buffers read=N         => pages read from disk (slow)

The Five Most Common Bad Plan Patterns

1. Seq Scan on a Large Table

sql
-- Bad: Seq Scan on orders (rows=1000000)
-- Cause: no index on the filter column, or statistics are stale

-- Fix: create index
CREATE INDEX idx_orders_status_created
    ON orders (status, created_at DESC)
    WHERE status = 'pending';  -- partial index if status has few values

-- Also run ANALYZE if statistics are stale:
ANALYZE orders;

2. Estimated vs Actual Rows Mismatch

sql
-- Bad: rows=10 (estimate) vs actual rows=94823
-- Cause: stale statistics or highly skewed data

-- Fix 1: update statistics
ANALYZE VERBOSE orders;

-- Fix 2: increase statistics target for skewed columns
ALTER TABLE orders ALTER COLUMN status SET STATISTICS 500;
ANALYZE orders;

-- Fix 3: use extended statistics for correlated columns
CREATE STATISTICS orders_status_region ON status, region FROM orders;
ANALYZE orders;

3. Nested Loop on Large Sets

sql
-- Bad: Nested Loop with inner Seq Scan (O(n*m) complexity)
-- Nested Loop  (actual rows=50000 loops=50000)  -- 2.5B iterations!

-- Fix: ensure join columns are indexed
CREATE INDEX idx_orders_customer_id ON orders (customer_id);

-- Or force a Hash Join for large datasets:
SET enable_nestloop = OFF;  -- temporary, for testing only

4. High Buffer Reads (Cache Misses)

sql
-- Bad: Buffers read=50000 (50K disk pages)
-- Cause: working set larger than shared_buffers, or cold cache

-- Check buffer pool hit rate:
SELECT
    sum(blks_hit)   AS cache_hits,
    sum(blks_read)  AS disk_reads,
    ROUND(sum(blks_hit)::numeric /
          NULLIF(sum(blks_hit) + sum(blks_read), 0) * 100, 2) AS hit_pct
FROM pg_stat_database
WHERE datname = current_database();
-- Target: hit_pct > 99%

-- Fix: increase shared_buffers (typically 25% of RAM)
-- shared_buffers = 8GB  # in postgresql.conf

5. Sort Without Index

sql
-- Bad: Sort  (cost=12345..12400 rows=50000)  using external merge (disk)
-- Cause: ORDER BY on unindexed column, or work_mem too low

-- Fix 1: index the sort column
CREATE INDEX idx_orders_created_desc ON orders (created_at DESC);

-- Fix 2: increase work_mem for sort-heavy sessions
SET work_mem = '256MB';  -- session-level, not global
Tip

Use explain.dalibo.com to paste your EXPLAIN (FORMAT JSON) output and get a visual flame-graph style plan tree. It makes the slowest node immediately obvious in complex multi-join plans.

Key Takeaways
  • Always use EXPLAIN (ANALYZE, BUFFERS) — plain EXPLAIN shows estimates only and can be misleading.
  • Large gaps between estimated and actual rows mean stale statistics — run ANALYZE and consider extended statistics for correlated columns.
  • High Buffers read means disk I/O is the bottleneck; fix with better indexes to reduce scan size or increase shared_buffers.
  • A Nested Loop on large inner sets is O(n*m) — always index join columns.

Working with JusDB on PostgreSQL Query Optimization

JusDB analyzes PostgreSQL query plans for engineering teams, translating EXPLAIN output into concrete index recommendations and query rewrites. Our DBAs have diagnosed thousands of slow queries across e-commerce, SaaS, and fintech deployments.

Explore JusDB PostgreSQL Services →  |  Talk to a DBA

Share this article

JusDB Team

Official JusDB content team