Skip to main content
GullySystem
A practical guide

Why a PostgreSQL query is slow: reading EXPLAIN ANALYZE

Ganesh HSStrategy and Technology, GullySystem
Practical advice · 4 min read

In this guide 6 sections
The short answer

Run EXPLAIN (ANALYZE, BUFFERS) on the slow query and look for the node with the largest actual time. Check for sequential scans on big tables, row estimates far from actual rows, sorts that spill to disk, and repeated queries from the app.

Run the plan on the real query

Copy the exact statement from your logs, including the parameter values. Put EXPLAIN (ANALYZE, BUFFERS) in front of it and run it in psql against a copy of production data. A tiny test database will show a different plan.

ANALYZE really executes the statement. For an UPDATE or DELETE, wrap it in BEGIN and finish with ROLLBACK. Otherwise you will change data while you investigate.

The output is a tree. Each node shows an estimated cost and rows, then actual time and actual rows. Read from the innermost, most indented node outward.

  • Use the real parameter values
  • Run on a restored copy of production data
  • Wrap writes in BEGIN and ROLLBACK
  • Save the plan text to compare after each change

Find the node that eats the time

Look at actual time on each node, then multiply by loops. A node showing two milliseconds that runs forty thousand times is your problem. The headline total hides that.

Compare rows against actual rows. When the planner expects ten rows and finds two hundred thousand, it picks a plan built for the wrong size. That mismatch is the most useful clue in the whole output.

Check the Buffers line too. Many shared reads mean data came from disk, not memory. Hits mean the table was already cached. Run the query twice and compare, since the first run warms the cache.

Read the usual suspects

A Seq Scan on a large table, with a Filter line removing most rows, usually means a missing index on the filtered column. A Seq Scan on a small table is fine and often faster.

An index that exists but goes unused is a different story. Causes include a function wrapped around the column, a type mismatch between the column and the parameter, or a leading wildcard in a LIKE pattern.

A Sort node showing Sort Method: external merge Disk means the sort did not fit in work_mem. Raise it for that session first, test, then decide if a composite index could return rows already ordered.

  • Seq Scan with a large Rows Removed by Filter
  • Index Scan never chosen despite an index
  • Sort Method external merge on disk
  • Nested Loop with a huge outer row count
  • Hash Join batches above one

Separate database problems from app problems

Sometimes every query is quick and the page is still slow. Turn on log_min_duration_statement, or use the pg_stat_statements extension, and count calls. If one page fires three hundred near identical SELECT statements, the app has an N+1 pattern.

The ORM loads a list of orders, then asks for each customer one by one. Fix it with a join, or load the related rows in one query using a list of ids.

No index will rescue that pattern. The cost is round trips, not scanning. Each trip pays for network latency, parsing and planning, however fast the lookup itself runs.

Fix in this order and measure each step

First, remove the repeated queries from the app. Second, add or correct one index and run ANALYZE on the table so statistics refresh. Third, rewrite the query if it pulls columns or rows the page never shows. Last, tune memory settings.

Make a single change per test and keep the before and after plans. An index that speeds one report can slow inserts on a busy table.

Tonight, pick your slowest list page, capture its plan and write down the three largest actual times. That note is your starting point.

Slow query diagnosis sheet

Every slow query gets a line on this single sheet. Columns hold the page name, SQL text, time before, the costliest node, estimated against actual rows, the fix tried, time after and date. A tick list covers Seq Scan, unused index, disk sort and N+1.

Open a blank worksheet to print

Questions owners ask

Is it safe to run EXPLAIN ANALYZE on production?

Reads are usually fine, though they use real resources. For writes, use a transaction you roll back, or better, a restored copy of the database.

Why does PostgreSQL ignore my index?

Common causes are a function applied to the column, mismatched types, a leading wildcard, or a table so small that scanning it is cheaper. Stale statistics can also mislead the planner.

What does Rows Removed by Filter tell me?

It counts rows read and then thrown away. A large number next to a small result suggests an index on the filtered column would let the database skip them.

How do I spot an N+1 problem?

Count statements per page request. Enable pg_stat_statements and look for one query with a very high call count. Your ORM’s debug log shows the same pattern.

Browse all guidesBack to top ↑
Next step

Have a specific situation to work through?

This article covers the general case. Tell us what you’re actually dealing with and we’ll respond directly.

Share Your Requirements