How to Read a PostgreSQL EXPLAIN ANALYZE Plan Without Getting Lost

PostgreSQL execution plans may look intimidating, but they simply show how the database finds and processes your data. Using a real query over millions of rows, this guide explains the key terms and provides a simple checklist for spotting performance problems.

PostgreSQL diagram showing IN and JOIN sharing an efficient execution plan while a correlated subquery performs repeated lookups.

PostgreSQL execution plans can feel like a wall of technical language:

Nested Loop
Index Only Scan
actual rows

Nested Loop
Index Only Scan
actual rows

Nested Loop
Index Only Scan
actual rows

The good news is that you do not need to understand every number immediately.

Start with one simple question:

Where did PostgreSQL spend work to produce the final rows?

This guide uses a real query over millions of rows and explains the plan in plain language. By the end, you will have a repeatable checklist for reading your own plans.


The query

We want orders that use an International shipping method:

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)
SELECT
    co.order_id,
    co.order_date,
    c.first_name,
    c.last_name,
    c.email
FROM cust_order AS co
JOIN customer AS c
    ON c.customer_id = co.customer_id
WHERE co.order_id <= 1000000
  AND co.shipping_method_id IN (
      SELECT sm.method_id
      FROM shipping_method AS sm
      WHERE sm.method_name = 'International'
  )

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)
SELECT
    co.order_id,
    co.order_date,
    c.first_name,
    c.last_name,
    c.email
FROM cust_order AS co
JOIN customer AS c
    ON c.customer_id = co.customer_id
WHERE co.order_id <= 1000000
  AND co.shipping_method_id IN (
      SELECT sm.method_id
      FROM shipping_method AS sm
      WHERE sm.method_name = 'International'
  )

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)
SELECT
    co.order_id,
    co.order_date,
    c.first_name,
    c.last_name,
    c.email
FROM cust_order AS co
JOIN customer AS c
    ON c.customer_id = co.customer_id
WHERE co.order_id <= 1000000
  AND co.shipping_method_id IN (
      SELECT sm.method_id
      FROM shipping_method AS sm
      WHERE sm.method_name = 'International'
  )

The database contains one million customers, one million synthetic shipping-method rows, and five million orders.

Here is the resulting plan:

The plan returned 1,000 rows in 13.140 ms. That is already our first useful conclusion: this query is not slow.


What the EXPLAIN options mean

The query uses:

EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)
EXPLAIN (ANALYZE, BUFFERS, TIMING OFF, SUMMARY ON)

ANALYZE

Without ANALYZE, PostgreSQL only estimates what it would do.

EXPLAIN SELECT
EXPLAIN SELECT
EXPLAIN SELECT

With ANALYZE, PostgreSQL actually executes the query and reports real row counts and runtime:

EXPLAIN ANALYZE SELECT
EXPLAIN ANALYZE SELECT
EXPLAIN ANALYZE SELECT

Be careful when using it with INSERT, UPDATE, or DELETE: those statements really execute.

BUFFERS

This reports how PostgreSQL accessed data pages:

shared hit
shared read
temp read
temp written
shared hit
shared read
temp read
temp written
shared hit
shared read
temp read
temp written

It helps distinguish memory activity from storage activity.

TIMING OFF

PostgreSQL still reports total execution time but avoids timing every individual row-processing operation. This reduces measurement overhead.

If you need per-node timing for diagnosis, run a separate plan without TIMING OFF:

EXPLAIN (ANALYZE, BUFFERS, SUMMARY ON)
SELECT

EXPLAIN (ANALYZE, BUFFERS, SUMMARY ON)
SELECT

EXPLAIN (ANALYZE, BUFFERS, SUMMARY ON)
SELECT

SUMMARY ON

This prints the planning and execution summary at the bottom.


Where should you start?

Use two passes.

First, start at the bottom summary:

Planning Time: 1.236 ms
Execution Time: 13.140 ms
Planning Time: 1.236 ms
Execution Time: 13.140 ms
Planning Time: 1.236 ms
Execution Time: 13.140 ms

This tells you whether there is a performance problem worth investigating.

Second, read the indented data-producing nodes from the deepest level upward. Child nodes produce rows for their parent nodes.

For this plan, the execution story is:

Find International shipping methods
        
Find orders using each method
        
Find the customer for each order
        
Return the final rows
Find International shipping methods
        
Find orders using each method
        
Find the customer for each order
        
Return the final rows
Find International shipping methods
        
Find orders using each method
        
Find the customer for each order
        
Return the final rows

Do not assume that PostgreSQL executed the SQL clauses from top to bottom. The optimizer is free to reorder joins and choose whichever access paths it estimates to be cheapest.


Step 1: find International shipping methods

The deepest starting node is:

Index Only Scan using idx_shipping_method_name_id
on shipping_method sm

Index Cond: (method_name = 'International')
actual rows=1000 loops=1
Heap Fetches: 0
Buffers: shared hit=9
Index Only Scan using idx_shipping_method_name_id
on shipping_method sm

Index Cond: (method_name = 'International')
actual rows=1000 loops=1
Heap Fetches: 0
Buffers: shared hit=9
Index Only Scan using idx_shipping_method_name_id
on shipping_method sm

Index Cond: (method_name = 'International')
actual rows=1000 loops=1
Heap Fetches: 0
Buffers: shared hit=9

In plain language:

Search the shipping-method index for “International.
Find 1,000 method IDs.
Do not open the main table.
Use nine cached data pages

Search the shipping-method index for “International.
Find 1,000 method IDs.
Do not open the main table.
Use nine cached data pages

Search the shipping-method index for “International.
Find 1,000 method IDs.
Do not open the main table.
Use nine cached data pages


Why Index Only Scan is good here

The index contains the filter column and the value required by the next step. PostgreSQL can answer this part using only the index.

Index Only Scan
        
Index contains everything required
        
Main table may not be visited
Index Only Scan
        
Index contains everything required
        
Main table may not be visited
Index Only Scan
        
Index contains everything required
        
Main table may not be visited

Heap Fetches: 0 confirms that the main table was not visited.


Step 2: find orders for those methods

The next node is:

Index Only Scan using idx_cust_order_shipping_customer
on cust_order co

Index Cond: (shipping_method_id = sm.method_id)
Filter: (order_id <= 1000000)
actual rows=1 loops=1000
Rows Removed by Filter: 4
Heap Fetches: 0
Buffers: shared hit=6024
Index Only Scan using idx_cust_order_shipping_customer
on cust_order co

Index Cond: (shipping_method_id = sm.method_id)
Filter: (order_id <= 1000000)
actual rows=1 loops=1000
Rows Removed by Filter: 4
Heap Fetches: 0
Buffers: shared hit=6024
Index Only Scan using idx_cust_order_shipping_customer
on cust_order co

Index Cond: (shipping_method_id = sm.method_id)
Filter: (order_id <= 1000000)
actual rows=1 loops=1000
Rows Removed by Filter: 4
Heap Fetches: 0
Buffers: shared hit=6024

PostgreSQL takes each of the 1,000 International method IDs and performs an indexed order lookup.

1,000 method IDs
        
1,000 order-index lookups
1,000 method IDs
        
1,000 order-index lookups
1,000 method IDs
        
1,000 order-index lookups

The line:

actual rows=1 loops=1000
actual rows=1 loops=1000
actual rows=1 loops=1000

means that this node ran 1,000 times and returned approximately one row per invocation.

Approximate total output:

1 row × 1,000 loops = 1,000 internal rows
1 row × 1,000 loops = 1,000 internal rows
1 row × 1,000 loops = 1,000 internal rows

PostgreSQL reports rows as an average per completed loop, and displayed values may be rounded. Multiplication gives an approximate total.


Rows removed by the filter

The plan says:

Rows Removed by Filter: 4
loops=1000
Rows Removed by Filter: 4
loops=1000
Rows Removed by Filter: 4
loops=1000

Approximately four rows were rejected per loop:

4 × 1,000 = approximately 4,000 rejected rows
4 × 1,000 = approximately 4,000 rejected rows
4 × 1,000 = approximately 4,000 rejected rows

The index found orders using a method ID, then PostgreSQL applied:

order_id <= 1000000
order_id <= 1000000
order_id <= 1000000

That condition appears as a Filter, not an Index Cond, because order_id is an included column in the current index rather than a searchable key.


Understanding INCLUDE in an index

The order index is:

CREATE INDEX idx_cust_order_shipping_customer
ON cust_order (shipping_method_id, customer_id)
INCLUDE (order_id, order_date)

CREATE INDEX idx_cust_order_shipping_customer
ON cust_order (shipping_method_id, customer_id)
INCLUDE (order_id, order_date)

CREATE INDEX idx_cust_order_shipping_customer
ON cust_order (shipping_method_id, customer_id)
INCLUDE (order_id, order_date)

Think of the two groups differently:

Search keys:
  shipping_method_id
  customer_id

Extra stored values:
  order_id
  order_date
Search keys:
  shipping_method_id
  customer_id

Extra stored values:
  order_id
  order_date
Search keys:
  shipping_method_id
  customer_id

Extra stored values:
  order_id
  order_date

PostgreSQL can navigate the index using the key columns. The included columns are payload: they can be returned from the index, but they are not used to navigate to a smaller range of index entries.

For example, this can use shipping_method_id as an index condition:

WHERE shipping_method_id = 1000
WHERE shipping_method_id = 1000
WHERE shipping_method_id = 1000

But this condition cannot use the included order_id as a search key in that index:

WHERE order_id <= 1000000
WHERE order_id <= 1000000
WHERE order_id <= 1000000

It becomes a filter after matching index entries are found.

A query-specific alternative would be:

CREATE INDEX idx_cust_order_shipping_order
ON cust_order (shipping_method_id, order_id)
INCLUDE (customer_id, order_date)

CREATE INDEX idx_cust_order_shipping_order
ON cust_order (shipping_method_id, order_id)
INCLUDE (customer_id, order_date)

CREATE INDEX idx_cust_order_shipping_order
ON cust_order (shipping_method_id, order_id)
INCLUDE (customer_id, order_date)

That could allow both conditions to appear under Index Cond. Do not add it automatically: the current query is already fast, and every index adds storage and write-maintenance cost.


Step 3: combine methods and orders

The first parent node is:

Nested Loop
estimated rows=1172
actual rows=1000 loops=1
Buffers: shared hit=6033
Nested Loop
estimated rows=1172
actual rows=1000 loops=1
Buffers: shared hit=6033
Nested Loop
estimated rows=1172
actual rows=1000 loops=1
Buffers: shared hit=6033

A nested loop works like this:

For each row from the outer side:
    find matching rows on the inner side
For each row from the outer side:
    find matching rows on the inner side
For each row from the outer side:
    find matching rows on the inner side

Here:

For each International method ID:
    use the order index to find matching orders
For each International method ID:
    use the order index to find matching orders
For each International method ID:
    use the order index to find matching orders

Nested loops are not automatically bad. They work well when the outer side is reasonably small and the inner side has a fast index lookup.

They become suspicious when the outer side contains millions of rows and the inner operation is repeated millions of times.

The buffer total comes from the child operations:

Shipping-method buffers:       9
Order buffers:              6,024
                              -----
Nested-loop total:          6,033
Shipping-method buffers:       9
Order buffers:              6,024
                              -----
Nested-loop total:          6,033
Shipping-method buffers:       9
Order buffers:              6,024
                              -----
Nested-loop total:          6,033


Step 4: find customers

The customer node is:

Index Scan using customer_pkey on customer c

Index Cond: (customer_id = co.customer_id)
actual rows=1 loops=1000
Buffers: shared hit=4000
Index Scan using customer_pkey on customer c

Index Cond: (customer_id = co.customer_id)
actual rows=1 loops=1000
Buffers: shared hit=4000
Index Scan using customer_pkey on customer c

Index Cond: (customer_id = co.customer_id)
actual rows=1 loops=1000
Buffers: shared hit=4000

PostgreSQL has 1,000 qualifying orders. It performs 1,000 customer primary-key lookups, finding one customer for each order.

It uses a regular Index Scan rather than Index Only Scan because the query needs:

first_name,
last_name,

first_name,
last_name,

first_name,
last_name,

Those values are in the customer table but not in the primary-key index.


Step 5: return the final rows

The top node is:

Nested Loop
estimated rows=1172
actual rows=1000 loops=1
Buffers: shared hit=10033
Nested Loop
estimated rows=1172
actual rows=1000 loops=1
Buffers: shared hit=10033
Nested Loop
estimated rows=1172
actual rows=1000 loops=1
Buffers: shared hit=10033

It combines qualifying orders with customer details and returns 1,000 rows.

The top buffer count includes its descendants:

Shipping-method pages:       9
Order pages:              6,024
Customer pages:           4,000
                           ------
Total logical accesses:  10,033
Shipping-method pages:       9
Order pages:              6,024
Customer pages:           4,000
                           ------
Total logical accesses:  10,033
Shipping-method pages:       9
Order pages:              6,024
Customer pages:           4,000
                           ------
Total logical accesses:  10,033


Cost is not time

A node may show:

cost=1.41..5835.51 rows=1172 width=62
cost=1.41..5835.51 rows=1172 width=62
cost=1.41..5835.51 rows=1172 width=62

This means:

  • 1.41: estimated startup cost

  • 5835.51: estimated total cost

  • rows=1172: estimated output rows

  • width=62: estimated average row size in bytes

Cost is an internal planner unit, not milliseconds. Use it to understand why PostgreSQL preferred one candidate plan over another. Use Execution Time to measure the actual runtime.


Estimated versus actual rows

The top node says:

estimated rows=1172
actual rows=1000
estimated rows=1172
actual rows=1000
estimated rows=1172
actual rows=1000

That estimate is reasonably close.

A dangerous mismatch looks like:

estimated rows=10
actual rows=1000000
estimated rows=10
actual rows=1000000
estimated rows=10
actual rows=1000000

If PostgreSQL expects 10 rows but receives one million, it may choose a nested loop where a hash join would be more appropriate.

Possible causes include stale statistics, uneven data, related columns that are estimated independently, or expressions that are difficult to estimate.

Start by updating statistics:


Buffer terms in plain language

PostgreSQL stores table and index data in pages, normally 8 KB each.

shared hit

Buffers: shared hit=10033
Buffers: shared hit=10033
Buffers: shared hit=10033

PostgreSQL found the requested pages in its shared memory cache.

shared read

Buffers: shared read=5000
Buffers: shared read=5000
Buffers: shared read=5000

PostgreSQL requested pages that were not already in shared buffers. The operating-system cache may still satisfy some reads, so this does not always mean a physical disk operation.


temp readandtemp written`

temp read=20000 written=20000
temp read=20000 written=20000
temp read=20000 written=20000

An operation used temporary storage, commonly because a sort or hash table did not fit in work_mem.

Your plan contains only shared hits. It was a warm-cache execution.


Heap fetches in plain language

The main PostgreSQL table storage is called the heap.

An index-only scan can still need to check the heap to confirm whether a row is visible to the current transaction:

Index Only Scan
Heap Fetches: 500000
Index Only Scan
Heap Fetches: 500000
Index Only Scan
Heap Fetches: 500000

That means PostgreSQL used the index but still visited the table many times.

Your plan shows:

Heap Fetches: 0
Heap Fetches: 0
Heap Fetches: 0

The visibility map allowed PostgreSQL to trust the index entries without checking the table. VACUUM helps maintain this visibility information, although frequently updated tables may naturally require heap checks.


Sort and hash spills

A sort that fits in memory may show:

Sort Method: quicksort
Memory: 4096kB
Sort Method: quicksort
Memory: 4096kB
Sort Method: quicksort
Memory: 4096kB

A sort that spills to temporary storage may show:

Sort Method: external merge
Disk: 500000kB
Sort Method: external merge
Disk: 500000kB
Sort Method: external merge
Disk: 500000kB

For a hash operation, multiple batches and temporary reads or writes can also indicate that the hash table did not fit in memory.

Possible responses include filtering earlier, adding an index that supplies the needed order, reducing the selected data, or carefully increasing session-level work_mem. Increasing work_mem globally can be dangerous because multiple operations and sessions may each receive their own allocation.


What a repeatedly executed SubPlan means

Consider this scalar correlated subquery:

SELECT co.order_id
FROM cust_order AS co
WHERE (
    SELECT sm.method_name
    FROM shipping_method AS sm
    WHERE sm.method_id = co.shipping_method_id
) = 'International'

SELECT co.order_id
FROM cust_order AS co
WHERE (
    SELECT sm.method_name
    FROM shipping_method AS sm
    WHERE sm.method_id = co.shipping_method_id
) = 'International'

SELECT co.order_id
FROM cust_order AS co
WHERE (
    SELECT sm.method_name
    FROM shipping_method AS sm
    WHERE sm.method_id = co.shipping_method_id
) = 'International'

Its plan may contain:

SubPlan 1
  Index Scan on shipping_method
  actual rows=1 loops=5000000
SubPlan 1
  Index Scan on shipping_method
  actual rows=1 loops=5000000
SubPlan 1
  Index Scan on shipping_method
  actual rows=1 loops=5000000

This means the internal lookup ran five million times and produced approximately one row per invocation.

It does not mean the final query returned five million rows. The subplan's result is used to test each outer order, and most orders may be rejected.

It also does not mean PostgreSQL sent five million separate SQL requests across the network. The database executor invoked that internal operation repeatedly inside one query execution.

A large loop count is important because even a small lookup becomes expensive when repeated millions of times.


Why the IN query has no SubPlan

The original condition is:

co.shipping_method_id IN (
    SELECT sm.method_id
    FROM shipping_method AS sm
    WHERE sm.method_name = 'International'
)
co.shipping_method_id IN (
    SELECT sm.method_id
    FROM shipping_method AS sm
    WHERE sm.method_name = 'International'
)
co.shipping_method_id IN (
    SELECT sm.method_id
    FROM shipping_method AS sm
    WHERE sm.method_name = 'International'
)

The inner query can be understood independently of the current order. PostgreSQL can convert the condition into semi-join logic:

Return an order if a matching International method ID exists
Return an order if a matching International method ID exists
Return an order if a matching International method ID exists

The plan therefore integrates shipping_method into the main join tree instead of showing a separate SubPlan.

This is why an uncorrelated IN query and an explicit join can have similar performance. See the full comparison in PostgreSQL IN vs JOIN vs Correlated Subquery.


A nine-point execution-plan checklist

When you open a plan, check these points in order.

1. Execution time

Is the query actually slow? Do not optimize a 13 ms query just because the plan looks complicated.

2. Estimated versus actual rows

Large differences can cause unsuitable join and scan choices.

3. Rows multiplied by loops

actual rows=1 loops=5000000
actual rows=1 loops=5000000
actual rows=1 loops=5000000

means approximately five million internal row results from that node.

4. Scan type

  • Seq Scan: reads the table page by page; good when much of the table is needed.

  • Index Scan: searches the index, then visits the table.

  • Index Only Scan: may return everything directly from the index.

5. Rows removed by filters

Reading one million rows and rejecting 999,990 may reveal a missing or unusable index. Rejecting four rows per loop in a fast query may be harmless.

6. Buffers

  • shared hit: page found in PostgreSQL memory

  • shared read: page requested outside shared buffers

  • temp read/written: temporary storage activity

7. Heap fetches

For an index-only scan, zero heap fetches is ideal. A high count means PostgreSQL still visited the table.

8. Sort or hash spills

Look for external merge, Disk, multiple hash batches, and temporary reads or writes.

9. Subplans

Do not treat every subplan as bad. Check its loop count, rows, buffers, and time. A cheap one-time subplan is very different from a lookup repeated five million times.


The plan in one sentence

Our example plan says:

PostgreSQL found 1,000 International method IDs using an index, performed indexed order lookups, retained 1,000 qualifying orders, performed 1,000 customer primary-key lookups, and returned the result in 13.140 ms using cached pages.

That is an efficient plan.


Final takeaway

Do not begin by asking whether Nested Loop, Seq Scan, or SubPlan is always bad. Begin by asking:

How much data did this node process?
How many times did it run?
How accurate were the estimates?
How much memory or storage activity did it create?
How much did it contribute to the final result

How much data did this node process?
How many times did it run?
How accurate were the estimates?
How much memory or storage activity did it create?
How much did it contribute to the final result

How much data did this node process?
How many times did it run?
How accurate were the estimates?
How much memory or storage activity did it create?
How much did it contribute to the final result

An execution plan is a data-flow story. Start with the summary, then follow the deepest scans upward until you can explain how their rows became the final result.

hiker in nature

Subscribe to my Newsletter

Sign up to stay updated about my latest work and adventures. No Spam, No BS. Promise!

hiker in nature

Subscribe to my Newsletter

Sign up to stay updated about my latest work and adventures. No Spam, No BS. Promise!