✦ Insights

Reading Postgres EXPLAIN Without Fear

Query plans look hostile until you know a handful of terms. A practical guide to reading EXPLAIN output, spotting bad estimates and fixing slow queries without guessing.

EngineeringAltisly Engineering16 June 20267 min read
All posts

A query that ran in forty milliseconds last month now takes eleven seconds. Someone pastes a wall of indented text into the chat, with arrows, costs and words like "Hash Right Join", and the room goes quiet. EXPLAIN output looks like it was written for the database, not for you.

It was, in a sense. But it is also a very honest document, and a small amount of vocabulary lets you read most of it. This guide is for engineers who write SQL daily and have been nodding politely at query plans.

What a plan actually is

When you send a query, PostgreSQL does not run it as written. SQL says what you want. The planner decides how to get it: which indexes to use, in what order to join tables, and which join method to apply. The plan is that decision, shown as a tree.

Read it from the innermost, most indented lines outward. The deepest nodes run first and feed rows upward to their parents. The top line is the final result.

There are two commands to know. EXPLAIN shows the plan the planner would choose, with estimates, without running the query. EXPLAIN (ANALYZE, BUFFERS) actually runs the query and reports what really happened, including time and memory pages touched. Use the second for diagnosis. Be careful with it on statements that write data, because ANALYZE executes them. Wrap those in a transaction and roll back.

A first plan, line by line

Take a simple lookup:

EXPLAIN (ANALYZE, BUFFERS)
SELECT * FROM payments WHERE account_id = 4821;

A typical result looks like this:

Index Scan using payments_account_id_idx on payments
  (cost=0.43..52.10 rows=40 width=96)
  (actual time=0.031..0.210 rows=38 loops=1)
  Index Cond: (account_id = 4821)
  Buffers: shared hit=45
Planning Time: 0.120 ms
Execution Time: 0.260 ms

Break it down:

  • Index Scan using ... is the method. The planner found an index on account_id and used it.
  • cost=0.43..52.10 is the planner's own unit of effort, not milliseconds. The first number is the cost to return the first row, the second to return all rows. Compare costs within one plan, never across machines.
  • rows=40 is the estimate. actual rows=38 is the truth. These are close, which is good.
  • loops=1 says this node ran once. This number matters more than it looks, as we will see.
  • Buffers: shared hit=45 means 45 pages were found in memory. A read figure would mean pages fetched from disk.

That is a healthy plan. Short, estimated well, and cheap.

Scan types you will meet

You will see the same handful of node types again and again.

Seq Scan reads the whole table. It is not automatically bad. For a small table, or a query that needs most rows, it is the fastest option. It is bad when it reads millions of rows to return a few.

Index Scan walks an index and fetches matching rows from the table. Index Only Scan answers entirely from the index when all needed columns are in it, skipping the table, provided the visibility information is up to date.

Bitmap Heap Scan sits between the two. It collects matching row locations from one or more indexes, then reads the table pages in order. You often see it when a filter matches a moderate share of rows, or when two indexes are combined.

The question is never "is there a Seq Scan". It is "does the amount of work match the number of rows I actually need".

Join methods

Joins have three main strategies, and each suits different shapes of data.

Nested Loop takes each row from the outer side and looks up matches on the inner side. It is excellent when the outer side is small and the inner side has an index. It is a disaster when the outer side is large and the inner lookup is slow, because the cost multiplies.

Hash Join builds an in memory hash table from one input and probes it with the other. It handles large, unsorted inputs well. If the hash table does not fit in work_mem, it spills to disk, and you will see batches greater than one.

Merge Join walks two sorted inputs together. It shines when both sides are already sorted, perhaps by index, and is common on big joins.

None is best. The planner picks based on estimated row counts, which brings us to the most important habit.

Compare estimates to actuals

Most slow plans have one cause: the planner guessed wrongly about how many rows it would see. It then picked a strategy that is right for the guess and wrong for reality.

Scan down the plan and compare rows= with the actual rows= for each node. A gap of a few percent is normal. A gap of ten times or a thousand times is the bug.

Nested Loop
  (cost=0.43..120.00 rows=5 width=64)
  (actual time=0.05..9800.00 rows=250000 loops=1)

Here the planner expected five rows and got a quarter of a million. It chose a nested loop for a few rows, then ground through a lot of them. The fix is not in the join, it is in the estimate.

Common reasons for bad estimates:

  • Statistics are stale. Run ANALYZE on the table, or check that autovacuum is keeping up.
  • Columns are correlated. The planner assumes city and postcode are independent, but they are not. Extended statistics with CREATE STATISTICS can describe the dependency.
  • A function or expression hides the column from statistics. A filter on lower(email) has no stats unless there is an expression index.
  • The value is skewed. One tenant owns most rows, and the default sample does not capture it well. Raising the statistics target for that column can help.

The loops trap

The actual time on a node is the average per loop, not the total. A node showing actual time=0.5..0.6 with loops=40000 has really taken about twenty seconds in total. Multiply before you conclude.

This catches people constantly in nested loops. An inner index scan that looks instant becomes the whole cost because it ran forty thousand times. Look for large loops values on the inner side of a nested loop, and ask whether a hash join would suit better, or whether the outer side returned far more rows than expected.

Sorts, memory and disk

Look for Sort Method. quicksort Memory: 120kB is fine. external merge Disk: 340000kB means the sort spilled to disk because it exceeded work_mem. Before raising that setting globally, remember it applies per operation per connection, so a large value multiplied by many sessions can exhaust memory. Consider raising it for the specific session or query instead, or adding an index that delivers rows already sorted.

Similarly, a Rows Removed by Filter figure far larger than the rows returned is a sign that the database read lots of data and threw most of it away. An index on the filtered column, or a better composite index, usually helps.

Indexes that actually help

A few rules of thumb hold up well.

For a query that filters on equality and sorts or ranges on another column, put the equality column first in a composite index. For frequently filtered subsets, a partial index such as one covering only status = 'pending' can be much smaller and faster. For columns you only read, an INCLUDE clause can enable index only scans.

And be sceptical of adding indexes by reflex. Every index slows writes and takes space. Add one after reading a plan that proves it would change the access path, then confirm with a new plan.

A routine that works

When a query is slow, follow the same path every time. Run EXPLAIN (ANALYZE, BUFFERS) on realistic data, not an empty development database. Find the node with the largest actual time, remembering to multiply by loops. Compare estimated and actual rows around it. Decide whether the cause is statistics, a missing index, or the shape of the query. Change one thing, then run the plan again.

Plans stop being frightening once you notice they are just a list of steps with a guess and a result beside each. Keep a few past plans from your own system and read them when nothing is wrong. The day something is, you will already know what normal looks like.

PostgreSQLPerformanceSQL
A

Altisly Engineering

Writing about systems architecture, operations software and what it takes to keep a build coherent, at Altisly.

Have a problem worth solving?

Tell us what is not working, what you are trying to build, or where you want to go next.

What happens next
01You describe the operation.
02We reply within one business day.
03If there is a fit, we tell you what the next step looks like.