Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

PostgreSQL in Depth  Module 3 – Query plans and indexes

Reading EXPLAIN ANALYZE in PostgreSQL 18

Read PostgreSQL 18 query plans from the deepest node up: costs, estimated and actual rows, loops, buffers, spills to disk and the options worth knowing.

  • Intermediate
  • 10 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 10 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Read a plan tree from the deepest node up using costs, estimated and actual rows, loops and buffers
  • Detect the warning signs in a plan, from estimates far from the actual rows to sorts and hashes that spill to disk
  • Pick the EXPLAIN option that answers a question, such as WAL, SETTINGS, SERIALIZE, GENERIC_PLAN or FORMAT JSON
  • Explain a data-changing statement safely inside a transaction that is rolled back

Before you start

What you’ll learn · Before you start

What you will learn

  • Read a plan tree from the deepest node up using costs, estimated and actual rows, loops and buffers
  • Detect the warning signs in a plan, from estimates far from the actual rows to sorts and hashes that spill to disk
  • Pick the EXPLAIN option that answers a question, such as WAL, SETTINGS, SERIALIZE, GENERIC_PLAN or FORMAT JSON
  • Explain a data-changing statement safely inside a transaction that is rolled back

Before you start

On this page

A report of pending orders reads 200,000 rows to keep 2,000, and its plan says so in one line: Rows Removed by Filter: 198000. EXPLAIN prints the plan PostgreSQL’s planner chose, with what it expects each step to cost and return; EXPLAIN ANALYZE runs the statement and adds what each step really returned, from which pages and with how much memory. Most slow queries are explained by the gap between those two sets of numbers.

You will read a plan tree from the deepest node up, multiply by loops, tell pages found in memory from pages read, recognise three warning signs, and choose the form of EXPLAIN that answers your question, including for a DELETE you do not want to keep.

EXPLAIN predicts, EXPLAIN ANALYZE measures

A query plan: The tree of steps PostgreSQL's planner chooses to run one statement: scans at the leaves, then joins, aggregates and sorts above them. EXPLAIN prints it, with estimated costs and rows for every step. is a tree of plan node: One step of a query plan, such as a Seq Scan, an Index Scan, a Hash Join or a Sort. It receives rows from the nodes below it and passes its own rows to the node above.: scans at the leaves, then joins, aggregates and sorts above them, each passing its rows to the node above. The run builds the shop of this lesson, 2,000 customers and 200,000 orders, and asks for pending orders per city twice: once with EXPLAIN, once with EXPLAIN ANALYZE:

Pending orders per city: the plan, then the plan run

plan_session.py

"""One query planned with EXPLAIN, then run and measured with EXPLAIN ANALYZE, on PostgreSQL 18.6."""

from pg_session import Server

# The same plan on every run: autovacuum off, no parallel workers or JIT, and statistics from every row.
SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("setup.sql", flags=("-q",))
    pg.psql("plan.sql")

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f plan.sql
-- Pending orders per city: first the plan the planner predicts, then the same plan run and measured.
-- TIMING OFF leaves out the clock readings of each node, which differ on every run; SUMMARY OFF the totals.
EXPLAIN
SELECT c.city, count(*) AS orders, sum(o.total) AS value
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending'
GROUP BY c.city ORDER BY c.city;
                                       QUERY PLAN                                       
----------------------------------------------------------------------------------------
 Sort  (cost=4050.38..4050.39 rows=5 width=46)
   Sort Key: c.city
   ->  HashAggregate  (cost=4050.26..4050.32 rows=5 width=46)
         Group Key: c.city
         ->  Hash Join  (cost=59.00..4035.26 rows=2000 width=13)
               Hash Cond: (o.customer_id = c.id)
               ->  Seq Scan on orders o  (cost=0.00..3971.00 rows=2000 width=11)
                     Filter: (status = 'pending'::text)
               ->  Hash  (cost=34.00..34.00 rows=2000 width=10)
                     ->  Seq Scan on customers c  (cost=0.00..34.00 rows=2000 width=10)
(10 rows)

EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT c.city, count(*) AS orders, sum(o.total) AS value
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending'
GROUP BY c.city ORDER BY c.city;
                                                      QUERY PLAN                                                      
----------------------------------------------------------------------------------------------------------------------
 Sort  (cost=4050.38..4050.39 rows=5 width=46) (actual rows=5.00 loops=1)
   Sort Key: c.city
   Sort Method: quicksort  Memory: 25kB
   Buffers: shared hit=1488
   ->  HashAggregate  (cost=4050.26..4050.32 rows=5 width=46) (actual rows=5.00 loops=1)
         Group Key: c.city
         Batches: 1  Memory Usage: 32kB
         Buffers: shared hit=1485
         ->  Hash Join  (cost=59.00..4035.26 rows=2000 width=13) (actual rows=2000.00 loops=1)
               Hash Cond: (o.customer_id = c.id)
               Buffers: shared hit=1485
               ->  Seq Scan on orders o  (cost=0.00..3971.00 rows=2000 width=11) (actual rows=2000.00 loops=1)
                     Filter: (status = 'pending'::text)
                     Rows Removed by Filter: 198000
                     Buffers: shared hit=1471
               ->  Hash  (cost=34.00..34.00 rows=2000 width=10) (actual rows=2000.00 loops=1)
                     Buckets: 2048  Batches: 1  Memory Usage: 102kB
                     Buffers: shared hit=14
                     ->  Seq Scan on customers c  (cost=0.00..34.00 rows=2000 width=10) (actual rows=2000.00 loops=1)
                           Buffers: shared hit=14
 Planning:
   Buffers: shared hit=12
(22 rows)

SELECT c.city, count(*) AS orders, sum(o.total) AS value
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending'
GROUP BY c.city ORDER BY c.city;
  city   | orders |   value   
---------+--------+-----------
 Chennai |    400 | 169600.00
 Kochi   |    400 | 219600.00
 Lucknow |    400 | 239600.00
 Mumbai  |    400 | 279600.00
 Pune    |    400 | 119800.00
(5 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 plan_session.py

plan.sql

-- Pending orders per city: first the plan the planner predicts, then the same plan run and measured.
-- TIMING OFF leaves out the clock readings of each node, which differ on every run; SUMMARY OFF the totals.
EXPLAIN
SELECT c.city, count(*) AS orders, sum(o.total) AS value
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending'
GROUP BY c.city ORDER BY c.city;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT c.city, count(*) AS orders, sum(o.total) AS value
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending'
GROUP BY c.city ORDER BY c.city;
SELECT c.city, count(*) AS orders, sum(o.total) AS value
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending'
GROUP BY c.city ORDER BY c.city;

setup.sql

-- The shop of this lesson: 2,000 customers, 400 in each of five cities, and 200,000 orders over 270 days.
-- The 2,000 newest orders, one per customer, are pending and unpaid; every other order is shipped and paid,
-- a dependency between two columns that the planner does not know about.
CREATE TABLE customers (id int PRIMARY KEY, name text NOT NULL, city text NOT NULL);
INSERT INTO customers
SELECT n, 'customer ' || n, (ARRAY['Pune', 'Mumbai', 'Chennai', 'Lucknow', 'Kochi'])[1 + (n - 1) / 400]
FROM generate_series(1, 2000) AS n;
CREATE TABLE orders (
  id bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
  customer_id int NOT NULL REFERENCES customers,
  status text NOT NULL,
  paid boolean NOT NULL,
  total numeric(10,2) NOT NULL,
  placed date NOT NULL
);
INSERT INTO orders (customer_id, status, paid, total, placed)
SELECT 1 + n % 2000, CASE WHEN n > 198000 THEN 'pending' ELSE 'shipped' END, n <= 198000,
       (n % 900) + 99.5, date '2026-01-01' + (n - 1) / 741
FROM generate_series(1, 200000) AS n;
CREATE INDEX orders_customer_idx ON orders (customer_id);
VACUUM ANALYZE customers, orders;

pg_session.py

"""Throwaway PostgreSQL 18.6 servers for the examples of this lesson.

Every session creates a cluster of its own with initdb in a new folder inside the examples folder, starts it, runs
psql scripts and server programs on it, prints what they printed, and stops and deletes it again, also when something
fails. The server is the pinned PostgreSQL 18.6 from the mise installations next to the Python that runs this file.
It listens only on 127.0.0.1, an address no other computer can reach, on a port the operating system reports as
unused, with no Unix socket, and it asks for a password (scram-sha-256) made up for the run and never printed.
learn:verify's run sandbox cannot read the home folder, where mise keeps PostgreSQL, so these sessions are recorded
with LEARN_VERIFY_NETWORK=allow; they read only this folder and the toolchain and connect to nothing but 127.0.0.1.

The output is exactly what the programs printed, with four substitutions so that a record never shows the computer
it ran on and is the same on every run: the run's folder is shown as ".", the folder of PostgreSQL's programs is left
out of their paths, the operating-system user's name is shown as "learner", and the dots pg_ctl prints while it waits
(one about every tenth of a second) as three. The sessions never print the port. A session stops with status 1 when
a command fails that should have worked, or reports a different number of errors than the session expects, so a run
that went wrong is never recorded as if it had worked.
"""

import os
import pwd
import re
import secrets
import shlex
import shutil
import socket
import subprocess
import sys
import tempfile
import textwrap
import time
from pathlib import Path

PG_VERSION = "18.6"
# .../installs/python/3.14.8/bin/python3 -> .../installs
INSTALLS = Path(sys.executable).resolve().parents[3]
BIN = INSTALLS / "postgres" / PG_VERSION / "bin"
OS_USER = pwd.getpwuid(os.geteuid()).pw_name


def free_port():
    """A TCP port on 127.0.0.1 that nothing listens on now."""
    with socket.socket() as probe:
        probe.bind(("127.0.0.1", 0))
        return probe.getsockname()[1]


class Server:
    """One throwaway cluster: initdb, settings, start; stopped and deleted when the with block ends."""

    def __init__(self, settings=None, initdb=(), name="data"):
        self.settings = dict(settings or {})
        self.initdb_args = list(initdb)
        self.name = name
        self.running = []

    def __enter__(self):
        if not (BIN / "postgres").exists():
            sys.exit(f"PostgreSQL {PG_VERSION} was not found next to this Python")
        self.base = Path(tempfile.mkdtemp(prefix="pg", dir=".")).resolve()
        self.port = free_port()
        self.password = secrets.token_urlsafe(24)
        (self.base / "pwfile").write_text(self.password + "\n")
        self.env = {**os.environ, "PGHOST": "127.0.0.1", "PGPORT": str(self.port), "PGUSER": "postgres",
                    "PGPASSWORD": self.password, "PGDATABASE": "postgres", "PGCONNECT_TIMEOUT": "10"}
        for key in ("PGSERVICE", "PGSERVICEFILE", "PGOPTIONS", "PSQLRC"):
            self.env.pop(key, None)
        for script in Path(".").glob("*.sql"):
            shutil.copy(script, self.base / script.name)
        for data in Path(".").glob("*.csv"):
            shutil.copy(data, self.base / data.name)
        return self

    def __exit__(self, *exc):
        for folder, _port in reversed(self.running):
            self._call(["pg_ctl", "-D", folder, "-m", "fast", "-w", "-t", "60", "stop"])
        self.running = []
        shutil.rmtree(self.base, ignore_errors=True)

    # ---- the cluster ---------------------------------------------------------------------------------------------
    def initdb(self, show=False):
        args = ["initdb", "-D", self.name, "-U", "postgres", "--auth=scram-sha-256", "--pwfile=pwfile",
                *self.initdb_args]
        if not show:
            args.append("--no-instructions")
        self._expect(args, show=show)
        with open(self.base / self.name / "postgresql.conf", "a") as conf:
            conf.write("\n# Added by the session: 127.0.0.1 only, no Unix socket.\n")
            conf.write(f"listen_addresses = '127.0.0.1'\nport = {self.port}\nunix_socket_directories = ''\n")
            for key, value in self.settings.items():
                conf.write(f"{key} = {value}\n")

    def start(self, show=False, folder=None, port=None):
        folder = folder or self.name
        self._expect(["pg_ctl", "-D", folder, "-l", f"{folder}.log", "-w", "-t", "60", "start"], show=show)
        self.running.append((folder, port or self.port))

    def up(self):
        """initdb and start, quietly."""
        self.initdb()
        self.start()
        return self

    def standby(self, folder="standby"):
        """A streaming standby of this server, made with pg_basebackup -R, started on a port of its own."""
        self._expect(["pg_basebackup", "-D", folder, "-R", "-X", "stream", "-c", "fast"])
        port = free_port()
        with open(self.base / folder / "postgresql.conf", "a") as conf:
            conf.write(f"port = {port}\n")
        self.start(folder=folder, port=port)
        for _ in range(300):
            if self.query("SELECT count(*) FROM pg_stat_replication WHERE state = 'streaming'") == "1":
                return port
            time.sleep(0.1)
        sys.exit("the standby did not start streaming")

    # ---- running things ------------------------------------------------------------------------------------------
    def _call(self, args, env=None):
        args = [str(a) for a in args]
        done = subprocess.run([str(BIN / args[0]), *args[1:]], stdout=subprocess.PIPE, stderr=subprocess.STDOUT,
                              text=True, cwd=self.base, env=env or self.env)
        return done.returncode, self.clean(done.stdout)

    def _expect(self, args, show=False, status=0):
        code, out = self._call(args)
        if show:
            _show(args, out)
        if code != status:
            if not show:
                print(out)
            sys.exit(f"{args[0]} ended with status {code}, not {status}")
        return out

    def clean(self, text):
        """The run's folder as ".", the user as learner, pg_ctl's waiting dots as three (see the docstring)."""
        text = text.replace(str(BIN) + "/", "").replace(str(self.base) + "/", "").replace(str(self.base), ".")
        text = re.sub(r"(waiting for server to (?:start|shut down))\.+", r"\1...", text)
        return text.replace(f'"{OS_USER}"', '"learner"')

    def wait_for(self, sql, value):
        """Waits (unprinted) until a query returns the value, for example after a reload."""
        for _ in range(300):
            if self.query(sql) == value:
                return
            time.sleep(0.1)
        sys.exit(f"{sql} never returned {value}")

    def show_file(self, path):
        """Prints a text file of the cluster, as `cat` would."""
        print(f"$ cat {path}")
        print((self.base / path).read_text().rstrip("\n"))
        print()

    def query(self, sql, port=None):
        """One value from one query, unprinted (the session uses it to wait or to decide)."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        code, out = self._call(["psql", "-X", "-At", "-c", sql], env=env)
        if code != 0:
            sys.exit(f"the query failed: {out}")
        return out.strip()

    def silent(self, sql):
        """Runs a statement without printing it (used only to give a role the run's password)."""
        self.query(sql)

    def psql(self, script, errors=0, status=None, user=None, db=None, port=None, flags=("-a",), note=None):
        """Runs a psql script with -X (no ~/.psqlrc) and the given flags, and prints the command and its output.
        `errors` is how many ERROR lines the script must print; `status` the exit status it must end with."""
        env = {**self.env, "PGPORT": str(port or self.port)}
        if user:
            env["PGUSER"] = user
        if db:
            env["PGDATABASE"] = db
        args = ["psql", "-X", *flags, "-f", script]
        code, out = self._call(args, env=env)
        shown = ["psql", "-X", *flags, "-f", script]
        if user:
            shown[1:1] = ["-U", user]
        if db:
            shown[1:1] = ["-d", db]
        if note:
            print(f"-- {note}")
        _show(shown, out)
        found = sum(1 for line in out.splitlines() if "ERROR:" in line or ": error:" in line)
        expected_status = 0 if status is None else status
        if found != errors or code != expected_status:
            sys.exit(f"{script}: {found} errors and status {code}; the session expects {errors} and {expected_status}")

    def sessions(self, count):
        """Opens `count` idle psql connections (each one a client backend) until the with block ends."""
        return _Idle(self, count)

    def shell(self, *args, status=0):
        """Runs one of PostgreSQL's programs and prints the command and its output."""
        return self._expect(list(args), show=True, status=status)

    def listing(self, folder, prefix=""):
        """Prints the names in a folder of the cluster (sorted, folders with a slash), as `ls -p` would list them."""
        path = self.base / folder
        names = sorted(n + ("/" if (path / n).is_dir() else "") for n in os.listdir(path) if n.startswith(prefix))
        print(f"-- in {folder}" + (f", the names that start with {prefix}" if prefix else "") + ":")
        print(textwrap.fill("  ".join(names), width=78, initial_indent="   ", subsequent_indent="   ",
                            break_on_hyphens=False))
        print()
        return names


def _arg(a):
    """A command-line argument as a reader would type it: double quotes around text with single quotes in it."""
    a = str(a)
    if re.fullmatch(r"[\w@%+=:,./-]+", a):
        return a
    if not re.search(r"[\"$`\\!]", a):
        return f'"{a}"'
    return shlex.quote(a)


def _show(args, out):
    print("$ " + " ".join(_arg(a) for a in args))
    if out.strip():
        print(out.rstrip("\n"))
    print()


class _Idle:
    """Idle psql connections, closed again when the with block ends."""

    def __init__(self, server, count):
        self.server, self.count, self.procs = server, count, []

    def __enter__(self):
        before = int(self.server.query("SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"))
        for _ in range(self.count):
            self.procs.append(subprocess.Popen([str(BIN / "psql"), "-X", "-q"], stdin=subprocess.PIPE,
                                               stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
                                               env=self.server.env, cwd=self.server.base))
        sql = "SELECT count(*) FROM pg_stat_activity WHERE backend_type = 'client backend'"
        for _ in range(300):
            # the query's own connection is one more client backend
            if int(self.server.query(sql)) == before + self.count:
                return self
            time.sleep(0.1)
        sys.exit("the idle connections did not open")

    def __exit__(self, *exc):
        for p in self.procs:
            p.stdin.close()
            p.wait(timeout=30)

Each node of the first plan shows cost=startup..total, the estimated rows it returns and their average width in bytes. Costs are planner units, not milliseconds. The manual’s formula for a sequential scan gives the scan of orders exactly: 1,471 pages at seq_page_cost 1.0, plus 200,000 rows at cpu_tuple_cost 0.01, plus the filter on each row at cpu_operator_cost 0.0025, is 1,471 + 2,000 + 500 = 3,971. A node’s total includes everything below it, which is why the Sort at the top costs 4,050.39.

The second plan adds, on every node, actual rows and loops. This lesson runs it with TIMING OFF, which leaves out the clock readings that would differ on every run, and SUMMARY OFF, which leaves out the total times. Read it from the deepest node up, following the rows:

Interactive · step by step The plan of pending orders per city, run node by node Step through the recorded plan and watch where the rows come from, how many each node passes up, and what it keeps in memory.
The plan of pending orders per city, run node by node, step 6 of 6. Showing Sort (5 rows, quicksort), HashAggregate (5 groups, 32kB), Hash Join, Hash (2,000 rows, 102kB), Seq Scan on orders (198,000 removed), Seq Scan on customers. In focus: Sort.Sort5 rows, quicksortHashAggregate5 groups, 32kBHash Joinon customer_idHash2,000 rows, 102kBSeq Scan on orders198,000 removedSeq Scan on customers14 pages

The drawing shows the whole scene; its 6 steps are listed below.

Every step as text
  1. The top node asks first. Execution starts at the root. Sort cannot sort anything until rows arrive, so it asks HashAggregate, which asks Hash Join, and so on down the tree. Rows then travel upwards, one node to the next.
  2. The hash is built first. A hash join reads its inner side before anything else. Seq Scan on customers reads 14 pages, and Hash stores all 2,000 customers by id in 102kB of memory, one batch.
  3. Orders stream past a filter. Seq Scan on orders reads all 1,471 pages and tests status = 'pending' on each of the 200,000 rows. It removes 198,000 and passes 2,000 up, exactly the planner's estimate.
  4. Each order finds its customer. For every pending order, Hash Join looks up customer_id in the hash and passes the joined row up. 2,000 rows come out, one per order.
  5. Five groups. HashAggregate keeps one running count and sum per city in a hash table of 32kB, then passes up its 5 groups. The estimate was 5 here too.
  6. The sort comes last. Sort orders the 5 rows by city with a quicksort in 25kB. Every node's estimated rows equalled its actual rows, so this plan was chosen on good information.

Every estimate here was right: 2,000 pending orders expected and found, 5 cities expected and found. That is the plan of a query the planner understood. The rest of this lesson is about the lines that show when it did not.

Comparing costs with times

A total cost of 4,050 says nothing about seconds. Costs only compare plans for the same query on the same server; to know how long a node took, run EXPLAIN ANALYZE with timing on and read its actual time, in milliseconds.

Checkpoint

Check: Estimates and measurements

  1. Question 1 of 2 A table has 1,000 pages and 50,000 rows. With the default cost constants, what total cost does the planner give a sequential scan of it without a filter?

    Type a number.

    A hint

    Pages times seq_page_cost (1.0) plus rows times cpu_tuple_cost (0.01).

    Show the answer: Question 1 of 2

    Answer: 1500 cost units

    1,000 × 1.0 + 50,000 × 0.01 = 1,500. The lesson's orders scan was 1,471 + 2,000 = 3,471, plus 500 for testing a filter on each of the 200,000 rows at cpu_operator_cost 0.0025: 3,971.

  2. Question 2 of 2 In the recorded plan of pending orders per city, where do the rows start, and where do they end?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: At the two scans at the bottom, passing up through the Hash Join and the HashAggregate to the Sort at the top

    Read a plan from the deepest node up: the scan of customers filled the Hash, the scan of orders streamed 2,000 rows into the join, 5 groups reached the Sort.

Actual rows are per loop, so multiply by loops

Some nodes run more than once. In a nested loop, the inner side runs once for every row of the outer side. The run asks for the orders of five customers:

A nested loop with five loops on its inner side

loops_session.py

"""A nested loop: the inner side runs once per outer row, and EXPLAIN ANALYZE counts rows per loop."""

from pg_session import Server

SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("setup.sql", flags=("-q",))
    pg.psql("loops.sql")

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f loops.sql
-- Five customers and their orders: the inner side of a nested loop runs once for every outer row.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT c.name, o.id, o.total
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.id <= 5;
                                                      QUERY PLAN                                                       
-----------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=5.35..1453.05 rows=500 width=28) (actual rows=500.00 loops=1)
   Buffers: shared hit=513
   ->  Index Scan using customers_pkey on customers c  (cost=0.28..8.37 rows=5 width=17) (actual rows=5.00 loops=1)
         Index Cond: (id <= 5)
         Index Searches: 1
         Buffers: shared hit=3
   ->  Bitmap Heap Scan on orders o  (cost=5.07..287.94 rows=100 width=19) (actual rows=100.00 loops=5)
         Recheck Cond: (c.id = customer_id)
         Heap Blocks: exact=500
         Buffers: shared hit=510
         ->  Bitmap Index Scan on orders_customer_idx  (cost=0.00..5.04 rows=100 width=0) (actual rows=100.00 loops=5)
               Index Cond: (customer_id = c.id)
               Index Searches: 5
               Buffers: shared hit=10
 Planning:
   Buffers: shared hit=254 read=6
(16 rows)

-- The same five customers, counted: 100 orders each.
SELECT count(*) AS orders FROM orders WHERE customer_id <= 5;
 orders 
--------
    500
(1 row)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 loops_session.py

loops.sql

-- Five customers and their orders: the inner side of a nested loop runs once for every outer row.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT c.name, o.id, o.total
FROM customers AS c JOIN orders AS o ON o.customer_id = c.id
WHERE c.id <= 5;
-- The same five customers, counted: 100 orders each.
SELECT count(*) AS orders FROM orders WHERE customer_id <= 5;

The index scan on customers_pkey ran once and returned 5 customers. For each of them, the bitmap scan of orders ran again: actual rows=100.00 loops=5. The manual’s rule is that rows and times are averages per execution, so the node returned 100 × 5 = 500 rows in total, which the count confirms. The estimate is per execution too: rows=100 on the inner side, rows=500 at the Nested Loop above it.

PostgreSQL 18 prints those averages with two decimals. Before version 18 an inner side that found a row on one loop in three printed rows=0, which hid the work; now it prints rows=0.33. The same release added Index Searches to index scans: 5 here, one descent of orders_customer_idx for each customer.

Key idea

Actual rows, like estimated rows, are per loop: a node’s real output is rows times loops, and an innocent-looking inner node with a large loops count is where a nested loop spends its time.

Checkpoint

Check: Rows per loop

  1. Question 1 of 2 An inner index scan shows actual rows=3.00 loops=400. How many rows did it return altogether?

    Type a number.

    A hint

    The rows are an average for one execution.

    Show the answer: Question 1 of 2

    Answer: 1200 rows

    3 × 400 = 1,200. The recorded bitmap scan showed rows=100.00 loops=5, 500 rows in total, which the count of the five customers' orders confirmed.

  2. Question 2 of 2 Why can PostgreSQL 18 print actual rows=0.33 for a node?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Rows are an average per loop, and version 18 prints the average with two decimals instead of rounding it

    The release notes of PostgreSQL 18 list fractional row counts among the EXPLAIN changes. Multiply by loops for the node's real output.

Buffers show where every page came from

PostgreSQL 18 adds a Buffers line to EXPLAIN ANALYZE without being asked; earlier versions needed the BUFFERS option. It counts 8 kB pages: shared hit for pages found in the shared buffers: PostgreSQL's cache of table and index pages in shared memory, used by every process of the server. Its size, shared_buffers (128MB by default), changes only with a restart. More in “PostgreSQL architecture: processes, memory, files”, shared read for pages the server had to ask the operating system for, dirtied and written for pages it changed or wrote out, and temp for the temporary files of sorts and hashes. The run restarts the server, which empties the shared buffers, and scans orders twice:

The same scan with empty shared buffers, then again

buffers_session.py

"""Buffers in EXPLAIN ANALYZE: the same scan right after a restart, with empty shared buffers, and then again."""

from pg_session import Server

SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("setup.sql", flags=("-q",))
    # A restart empties the shared buffers; the operating system may still cache the files.
    pg.shell("pg_ctl", "-D", "data", "-l", "data.log", "restart")
    pg.psql("buffers.sql", note="the first scan after the restart")
    pg.psql("buffers.sql", note="the same scan again")

Output

$ psql -X -q -f setup.sql

$ pg_ctl -D data -l data.log restart
waiting for server to shut down... done
server stopped
waiting for server to start... done
server started

-- the first scan after the restart
$ psql -X -a -f buffers.sql
-- One full scan of orders. PostgreSQL 18 adds the Buffers lines to EXPLAIN ANALYZE without being asked.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM orders WHERE paid;
                                             QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
 Aggregate  (cost=3966.00..3966.01 rows=1 width=8) (actual rows=1.00 loops=1)
   Buffers: shared read=1471
   ->  Seq Scan on orders  (cost=0.00..3471.00 rows=198000 width=0) (actual rows=198000.00 loops=1)
         Filter: paid
         Rows Removed by Filter: 2000
         Buffers: shared read=1471
 Planning:
   Buffers: shared hit=55 read=18 dirtied=1
(8 rows)

-- the same scan again
$ psql -X -a -f buffers.sql
-- One full scan of orders. PostgreSQL 18 adds the Buffers lines to EXPLAIN ANALYZE without being asked.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM orders WHERE paid;
                                             QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
 Aggregate  (cost=3966.00..3966.01 rows=1 width=8) (actual rows=1.00 loops=1)
   Buffers: shared hit=1471
   ->  Seq Scan on orders  (cost=0.00..3471.00 rows=198000 width=0) (actual rows=198000.00 loops=1)
         Filter: paid
         Rows Removed by Filter: 2000
         Buffers: shared hit=1471
 Planning:
   Buffers: shared hit=78
(8 rows)

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 buffers_session.py

buffers.sql

-- One full scan of orders. PostgreSQL 18 adds the Buffers lines to EXPLAIN ANALYZE without being asked.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT count(*) FROM orders WHERE paid;

The first scan read all 1,471 pages: shared read=1471. The second found every one in memory: shared hit=1471. Same plan, same rows, very different work. The Planning line counts the catalog pages the planner itself looked at: 55 hits and 18 reads after the restart, 78 hits the second time.

read does not mean disk

shared read counts pages PostgreSQL did not have, not pages that came from the disk: the operating system’s own cache may have served them. A query that is fast in testing and slow in production often differs only in this line.

When you compare two plans, compare their buffers as well as their rows: the nested loop of the next section makes 7,471 buffer accesses to return 2,000 rows, five times the 1,485 of the hash join above, whatever the clock says on a warm cache.

Checkpoint

Check: Buffer counts

  1. Question 1 of 2 Right after a restart a scan of orders showed shared read=1471; run again it showed shared hit=1471. What changed between the two runs?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The 1,471 pages were in the shared buffers the second time, put there by the first run

    hit counts pages found in shared buffers and read counts pages PostgreSQL had to request. Compare plans on the same cache state, or compare their total buffers.

  2. Question 2 of 2 A node shows Buffers shared hit=1200 read=300. How many 8 kB pages did it access?

    Type a number.

    Show the answer: Question 2 of 2

    Answer: 1500 pages

    Hits and reads together are the pages it touched: 1,200 found in memory and 300 requested from the operating system. The lesson's first scan after the restart was 0 hits and 1,471 reads.

Three warning signs: a wrong estimate, discarded rows, a spill

The run shows the three lines worth looking for first, on the same data:

An estimate 100 times too low, a sort and a hash that spill

warnings_session.py

"""Three warning signs in EXPLAIN ANALYZE: an estimate far from the actual rows, a sort and a hash that spill."""

from pg_session import Server

SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("setup.sql", flags=("-q",))
    pg.psql("warnings.sql")

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f warnings.sql
-- 1. An estimate 100 times too low: the planner multiplies the two conditions as if they were independent.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT c.city, o.id, o.total
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending' AND NOT o.paid;
                                                      QUERY PLAN                                                       
-----------------------------------------------------------------------------------------------------------------------
 Nested Loop  (cost=0.28..4056.90 rows=20 width=21) (actual rows=2000.00 loops=1)
   Buffers: shared hit=7471
   ->  Seq Scan on orders o  (cost=0.00..3971.00 rows=20 width=19) (actual rows=2000.00 loops=1)
         Filter: ((NOT paid) AND (status = 'pending'::text))
         Rows Removed by Filter: 198000
         Buffers: shared hit=1471
   ->  Index Scan using customers_pkey on customers c  (cost=0.28..4.29 rows=1 width=10) (actual rows=1.00 loops=2000)
         Index Cond: (id = o.customer_id)
         Index Searches: 2000
         Buffers: shared hit=6000
 Planning:
   Buffers: shared hit=271 read=7
(12 rows)

-- 2. A sort that does not fit in work_mem writes to disk; with enough memory it does not.
SET work_mem = '256kB';
SET
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT * FROM orders ORDER BY total;
                                             QUERY PLAN                                              
-----------------------------------------------------------------------------------------------------
 Sort  (cost=35444.64..35944.64 rows=200000 width=32) (actual rows=200000.00 loops=1)
   Sort Key: total
   Sort Method: external merge  Disk: 8256kB
   Buffers: shared hit=1474, temp read=3090 written=3165
   ->  Seq Scan on orders  (cost=0.00..3471.00 rows=200000 width=32) (actual rows=200000.00 loops=1)
         Buffers: shared hit=1471
 Planning:
   Buffers: shared hit=15
(8 rows)

SET work_mem = '64MB';
SET
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT * FROM orders ORDER BY total;
                                             QUERY PLAN                                              
-----------------------------------------------------------------------------------------------------
 Sort  (cost=21080.64..21580.64 rows=200000 width=32) (actual rows=200000.00 loops=1)
   Sort Key: total
   Sort Method: quicksort  Memory: 15520kB
   Buffers: shared hit=1471
   ->  Seq Scan on orders  (cost=0.00..3471.00 rows=200000 width=32) (actual rows=200000.00 loops=1)
         Buffers: shared hit=1471
(6 rows)

-- 3. A hash aggregate that does not fit splits its groups into batches on disk.
SET work_mem = '256kB';
SET
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT customer_id, placed, count(*) FROM orders GROUP BY customer_id, placed;
                                             QUERY PLAN                                             
----------------------------------------------------------------------------------------------------
 HashAggregate  (cost=15221.00..16983.50 rows=20000 width=16) (actual rows=200000.00 loops=1)
   Group Key: customer_id, placed
   Planned Partitions: 4  Batches: 85  Memory Usage: 601kB  Disk Usage: 7272kB
   Buffers: shared hit=1471, temp read=1592 written=2326
   ->  Seq Scan on orders  (cost=0.00..3471.00 rows=200000 width=8) (actual rows=200000.00 loops=1)
         Buffers: shared hit=1471
 Planning:
   Buffers: shared hit=15 read=1
(8 rows)

RESET work_mem;
RESET

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 warnings_session.py

warnings.sql

-- 1. An estimate 100 times too low: the planner multiplies the two conditions as if they were independent.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF)
SELECT c.city, o.id, o.total
FROM orders AS o JOIN customers AS c ON c.id = o.customer_id
WHERE o.status = 'pending' AND NOT o.paid;
-- 2. A sort that does not fit in work_mem writes to disk; with enough memory it does not.
SET work_mem = '256kB';
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT * FROM orders ORDER BY total;
SET work_mem = '64MB';
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT * FROM orders ORDER BY total;
-- 3. A hash aggregate that does not fit splits its groups into batches on disk.
SET work_mem = '256kB';
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF) SELECT customer_id, placed, count(*) FROM orders GROUP BY customer_id, placed;
RESET work_mem;

An estimate far from the actual rows. The query asks for orders that are pending and unpaid. The planner knows that 1 % of orders are pending and 1 % unpaid, and, treating the two columns as independent, multiplies them: 0.01 % of 200,000, rows=20. In this shop the two conditions are the same 2,000 orders, so the actual rows were 2,000, 100 times the estimate. Expecting 20 rows, the planner chose a nested loop with an index lookup per order, and it ran loops=2000 with 6,000 buffer hits. The statistics lesson of this track teaches the fix, extended statistics on the two columns.

Rows removed by a filter. Rows Removed by Filter: 198000 means the scan read 200,000 rows to keep 2,000. On a small table that is fine; on a large one it is the first sign that an index, or a partial index, would pay off, which the next lessons of this module build.

A sort or a hash that spills. With work_mem at 256kB the sort of 200,000 orders printed Sort Method: external merge Disk: 8256kB and temp read=3090 written=3165; with 64MB it sorted in memory, quicksort Memory: 15520kB. A hash aggregate spills differently: with 256kB it had planned 4 partitions for the 20,000 groups it expected, met 200,000 and printed Batches: 85 and Disk Usage: 7272kB.

Fix the estimate before the plan

When estimated and actual rows differ by ten times or more, change what the planner knows (fresh ANALYZE, more statistics, extended statistics) before you force a plan. A better estimate fixes this query and every other query that uses the same columns.

Checkpoint

Check: The warning signs of a plan

  1. Question 1 of 2 A node estimated rows=20 and returned actual rows=2000.00 with loops=1. By what factor was the estimate too low?

    Type a number.

    Show the answer: Question 1 of 2

    Answer: 100 times

    2,000 ÷ 20 = 100. The planner multiplied 1 % pending by 1 % unpaid as if they were independent, and the two conditions were the same 2,000 orders.

  2. Question 2 of 2 Which of these recorded lines show that an operation wrote temporary files?

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • Sort Method: external merge Disk: 8256kB
    • Buffers: shared hit=1474, temp read=3090 written=3165
    • Batches: 85 Memory Usage: 601kB Disk Usage: 7272kB

    Disk, Batches above 1 and temp buffers mean spilling. More work_mem for that query, or less data to sort or group, removes them.

Choose the option that answers your question

EXPLAIN takes options in parentheses, and each answers a different question. The run tries five of them:

GENERIC_PLAN, SERIALIZE, MEMORY, SETTINGS, WAL and FORMAT JSON

options_session.py

"""EXPLAIN's options for particular questions, and a plan in FORMAT JSON read by a program."""

import json

from pg_session import Server

SETTINGS = {"autovacuum": "off", "max_parallel_workers_per_gather": 0, "jit": "off",
            "default_statistics_target": 1000, "timezone": "'UTC'"}

QUERY = ("SELECT c.city, o.id, o.total FROM orders AS o JOIN customers AS c ON c.id = o.customer_id "
         "WHERE o.status = 'pending' AND NOT o.paid")


def nodes(plan, depth=0):
    """Every node of a JSON plan, with its depth in the tree."""
    yield depth, plan
    for child in plan.get("Plans", []):
        yield from nodes(child, depth + 1)


with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("setup.sql", flags=("-q",))
    pg.psql("options.sql")
    # FORMAT JSON is for programs: read the plan of the pending-and-unpaid query and compare each node's estimate
    # with what it really returned. Both counts are per loop: the inner side of a nested loop runs once per outer row.
    plan = json.loads(pg.query(f"EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, FORMAT JSON) {QUERY}"))[0]["Plan"]
    print("-- EXPLAIN (ANALYZE, FORMAT JSON) of the pending-and-unpaid query, read by Python (rows per loop):")
    print(f"{'node':<30}{'estimated':>10}{'actual':>9}{'loops':>7}{'actual/est':>12}")
    for depth, node in nodes(plan):
        name = "  " * depth + node["Node Type"]
        if "Relation Name" in node:
            name += f" on {node['Relation Name']}"
        estimated, actual = node["Plan Rows"], node["Actual Rows"]
        print(f"{name:<30}{estimated:>10}{actual:>9.0f}{node['Actual Loops']:>7}{actual / estimated:>12.1f}")
    print()

Output

$ psql -X -q -f setup.sql

$ psql -X -a -f options.sql
-- GENERIC_PLAN: the plan for a statement with a parameter, without a value for it (PostgreSQL 16 and later).
EXPLAIN (GENERIC_PLAN) SELECT * FROM orders WHERE customer_id = $1;
                                     QUERY PLAN                                     
------------------------------------------------------------------------------------
 Bitmap Heap Scan on orders  (cost=5.07..319.59 rows=100 width=32)
   Recheck Cond: (customer_id = $1)
   ->  Bitmap Index Scan on orders_customer_idx  (cost=0.00..5.04 rows=100 width=0)
         Index Cond: (customer_id = $1)
(4 rows)

-- SERIALIZE and MEMORY (PostgreSQL 17 and later): the bytes sent to the client and the planner's own memory.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, SERIALIZE, MEMORY) SELECT * FROM orders WHERE customer_id = 42;
                                                   QUERY PLAN                                                    
-----------------------------------------------------------------------------------------------------------------
 Bitmap Heap Scan on orders  (cost=5.07..319.59 rows=100 width=32) (actual rows=100.00 loops=1)
   Recheck Cond: (customer_id = 42)
   Heap Blocks: exact=100
   Buffers: shared hit=100 read=2
   ->  Bitmap Index Scan on orders_customer_idx  (cost=0.00..5.04 rows=100 width=0) (actual rows=100.00 loops=1)
         Index Cond: (customer_id = 42)
         Index Searches: 1
         Buffers: shared read=2
 Planning:
   Memory: used=13kB  allocated=32kB
 Serialization: output=6kB  format=text
(11 rows)

-- SETTINGS: every planner setting that differs from its default.
EXPLAIN (SETTINGS) SELECT * FROM orders WHERE customer_id = 42;
                                     QUERY PLAN                                     
------------------------------------------------------------------------------------
 Bitmap Heap Scan on orders  (cost=5.07..319.59 rows=100 width=32)
   Recheck Cond: (customer_id = 42)
   ->  Bitmap Index Scan on orders_customer_idx  (cost=0.00..5.04 rows=100 width=0)
         Index Cond: (customer_id = 42)
 Settings: max_parallel_workers_per_gather = '0', jit = 'off'
(5 rows)

-- ANALYZE runs a DELETE for real, so run it in a transaction and roll it back; WAL shows what it wrote.
BEGIN;
BEGIN
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, WAL) DELETE FROM orders WHERE status = 'pending';
                                           QUERY PLAN                                           
------------------------------------------------------------------------------------------------
 Delete on orders  (cost=0.00..3971.00 rows=0 width=0) (actual rows=0.00 loops=1)
   Buffers: shared hit=3487
   WAL: records=2000 bytes=108128
   ->  Seq Scan on orders  (cost=0.00..3971.00 rows=2000 width=6) (actual rows=2000.00 loops=1)
         Filter: (status = 'pending'::text)
         Rows Removed by Filter: 198000
         Buffers: shared hit=1471
 Planning:
   Buffers: shared hit=10 read=1
(9 rows)

ROLLBACK;
ROLLBACK
SELECT count(*) AS pending_orders FROM orders WHERE status = 'pending';
 pending_orders 
----------------
           2000
(1 row)

-- EXPLAIN (ANALYZE, FORMAT JSON) of the pending-and-unpaid query, read by Python (rows per loop):
node                           estimated   actual  loops  actual/est
Nested Loop                           20     2000      1       100.0
  Seq Scan on orders                  20     2000      1       100.0
  Index Scan on customers              1        1   2000         1.0

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 options_session.py

options.sql

-- GENERIC_PLAN: the plan for a statement with a parameter, without a value for it (PostgreSQL 16 and later).
EXPLAIN (GENERIC_PLAN) SELECT * FROM orders WHERE customer_id = $1;
-- SERIALIZE and MEMORY (PostgreSQL 17 and later): the bytes sent to the client and the planner's own memory.
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, SERIALIZE, MEMORY) SELECT * FROM orders WHERE customer_id = 42;
-- SETTINGS: every planner setting that differs from its default.
EXPLAIN (SETTINGS) SELECT * FROM orders WHERE customer_id = 42;
-- ANALYZE runs a DELETE for real, so run it in a transaction and roll it back; WAL shows what it wrote.
BEGIN;
EXPLAIN (ANALYZE, TIMING OFF, SUMMARY OFF, WAL) DELETE FROM orders WHERE status = 'pending';
ROLLBACK;
SELECT count(*) AS pending_orders FROM orders WHERE status = 'pending';

GENERIC_PLAN explained customer_id = $1 without a value, the plan a prepared statement can reuse for any customer: a generic plan: A plan made for a statement with parameters ($1, $2 …) without knowing their values, so that it can be reused for any value. EXPLAIN (GENERIC_PLAN) shows one.. SERIALIZE measured the conversion of the result into text for the client, output=6kB for 100 rows, without sending it, and MEMORY the planner’s own memory, used=13kB. SETTINGS listed the planner settings that differ from their defaults on this server, max_parallel_workers_per_gather = '0' and jit = 'off', which is how a plan copied from one server tells you why it differs on another.

EXPLAIN ANALYZE executes the statement, so a DELETE really deletes. The run wrapped one in BEGIN and ROLLBACK: the plan reported WAL: records=2000 bytes=108128, one record per deleted row, and afterwards all 2,000 pending orders were still there. Finally, FORMAT JSON gave the plan of the pending-and-unpaid query as data, and a few lines of Python printed each node’s estimate beside its actual rows: 100.0 times the estimate on the scan and the join, 1.0 on the index lookup.

Answer two questions about what you are investigating:

Interactive · answer and compare Which EXPLAIN do I need? Answer two questions about what you are investigating, and the table rules out the forms that would not answer it or would be unsafe.
Does the statement change data?
What do you need to know?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Six forms of EXPLAIN, against whether they run the statement, what they show and whether they need values
Form Leaves the data alone Shows what really happened Works without parameter values
The plan only Statement: EXPLAIN Good plans the statement and runs nothing Poor estimates only: costs, rows and widths Poor a $1 without a value is an error
The plan run and measured Statement: EXPLAIN (ANALYZE) Poor runs the statement: an UPDATE really updates Good actual rows, loops and, in 18, buffers Poor needs real values
The WAL a change writes Statement: EXPLAIN (ANALYZE, WAL) Poor runs the change; wrap it in BEGIN and ROLLBACK Good WAL records and bytes, 2,000 records for the recorded DELETE Poor needs real values
Rows converted to text Statement: EXPLAIN (ANALYZE, SERIALIZE) Mixed runs the statement; safe for a SELECT Good the bytes converted for the client Poor needs real values
A plan for any value Statement: EXPLAIN (GENERIC_PLAN) Good runs nothing Poor estimates only, for an unknown value Good made for $1, $2 …
A plan for a program Statement: EXPLAIN (ANALYZE, FORMAT JSON) Mixed with ANALYZE it runs the statement, as the text form does Good the same numbers as keys a program can read Poor needs real values
How the answers decide
  • “Does the statement change data?” Yes: INSERT, UPDATE or DELETE: adds a caveat to The plan run and measured, The WAL a change writes and A plan for a program. ANALYZE executes the change. Run it inside BEGIN and ROLLBACK, as the recorded DELETE did, or on a copy of the data.
  • “What do you need to know?” Why a query is slow: rules out The plan only, A plan for any value and The WAL a change writes. Only a measured run shows where the rows and the pages really went; estimates alone cannot show a wrong estimate.
  • “What do you need to know?” How much WAL a change writes: rules out The plan only, A plan for any value, Rows converted to text and A plan for a program. The WAL option adds the records and bytes a statement wrote, which no other form reports.
  • “What do you need to know?” The plan for a statement with $1: rules out The plan only, The plan run and measured, The WAL a change writes, Rows converted to text and A plan for a program. Every other form needs a value for $1; GENERIC_PLAN shows the plan that suits any value.
  • “What do you need to know?” Numbers a script can check: rules out The plan only, The WAL a change writes, Rows converted to text and A plan for any value and adds a caveat to The plan run and measured. FORMAT JSON gives the same numbers as named keys, which a program reads without parsing text.

To see plans of slow statements in production without running EXPLAIN yourself, the auto_explain module writes the plan of every statement slower than a threshold to the server log; this track’s monitoring module sets it up.

Checkpoint

Check: EXPLAIN options

  1. Question 1 of 2 Which statement shows a plan for SELECT * FROM orders WHERE customer_id = $1 without a value for $1?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: EXPLAIN (GENERIC_PLAN) SELECT * FROM orders WHERE customer_id = $1

    A generic plan is what a prepared statement may reuse for every value, which the plan-cache lesson of this track returns to.

  2. Question 2 of 2 You need the measured plan of a slow DELETE on a production table, without deleting anything. What do you run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: BEGIN, then EXPLAIN (ANALYZE) of the DELETE, then ROLLBACK, at a quiet time, because it still runs and locks

    The manual gives exactly this pattern for INSERT, UPDATE, DELETE and MERGE. The statement holds its locks and fires its triggers until the rollback.

Practise: read the plans of the shop

Each question below refers to one of the recorded plans: the estimate check, the nested loop, the buffers and the spills. Before you begin, take the pending-and-unpaid plan and say, node by node, which number you would show a colleague first and why:

Practice that adapts to you

Practise this lesson

10 questions about 5 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 10 A plan's scan shows cost=0.00..3971.00. What does 3971.00 measure?

    Choose one answer.

    Show the answer: Question 1 of 10

    Answer: The planner's estimated total work for the node, in units based on reading one page in sequence

    The first number is the startup cost, before the first row, and the second the total, for all rows.

  2. Question 2 of 10 A colleague tunes a slow query by reading plain EXPLAIN output only, because it is quick. Which problem from this lesson can that never show?

    Choose one answer.

    Show the answer: Question 2 of 10

    Answer: An estimate far from reality, such as rows=20 for a node that returns 2,000

    Compare estimated and actual rows node by node; that comparison needs EXPLAIN ANALYZE, safely wrapped when the statement changes data.

  3. Question 3 of 10 A nested loop's inner node shows rows=100 inside its cost brackets and, after ANALYZE, loops=5. How many rows in total did the planner expect from that node?

    Type a number.

    Show the answer: Question 3 of 10

    Answer: 500 rows

    Estimates are per execution, like actual rows: 100 per loop for 5 outer rows, and the Nested Loop above showed rows=500.

  4. Question 4 of 10 A plan shows a Nested Loop whose inner Index Scan has actual rows=1.00 loops=2000 and Index Searches 2000. The outer node estimated 20 rows. What do you conclude?

    Choose one answer.

    Show the answer: Question 4 of 10

    Answer: The planner chose the nested loop expecting 20 lookups and made 2,000, so the outer estimate is the thing to fix

    Large loops counts multiply small costs. Trace them to the estimate that produced them, here the pending-and-unpaid filter.

  5. Question 5 of 10 Does Buffers shared read=1471 prove that 1,471 pages came from the disk?

    Choose one answer.

    Show the answer: Question 5 of 10

    Answer: No; PostgreSQL asked the operating system for them, which may have served them from its own cache

    The track's monitoring lessons show how to measure I/O timing; EXPLAIN's buffer counts only say where PostgreSQL itself found the pages.

  6. Question 6 of 10 Two plans for the same query: A makes 7,471 buffer accesses and B makes 1,485. A runs faster than B in your test. What should you suspect?

    Choose one answer.

    Show the answer: Question 6 of 10

    Answer: Your test runs on a warm cache; A does about five times the work and may be the slower one when pages must be read

    The lesson's nested loop made 7,471 accesses for 2,000 rows where the hash join made 1,485 for the same number of pending orders.

  7. Question 7 of 10 What does Rows Removed by Filter 198000 on a scan that returned 2,000 rows tell you?

    Choose one answer.

    Show the answer: Question 7 of 10

    Answer: The scan read 200,000 rows to keep 2,000; on a large table that suggests an index or a partial index

    The next lessons of this module build the indexes that let a query read only the rows it needs.

  8. Question 8 of 10 A HashAggregate shows Planned Partitions 4, Batches 85 and Disk Usage 7272kB, with an estimate of 20,000 groups and 200,000 actual groups. Which two facts explain the spill?

    Choose one answer.

    Show the answer: Question 8 of 10

    Answer: work_mem was too small for the groups, and the planner expected ten times fewer groups than there were

    A hash may use work_mem × hash_mem_multiplier, 2.0 by default, so 512kB at work_mem 256kB. More memory for the query, or a correct estimate, would keep it in one batch.

  9. Question 9 of 10 Which form of EXPLAIN reports how many WAL records and bytes a statement wrote?

    Choose one answer.

    Show the answer: Question 9 of 10

    Answer: EXPLAIN (ANALYZE, WAL)

    Run it inside BEGIN and ROLLBACK when the statement changes data.

  10. Question 10 of 10 A nightly script must flag any plan node whose actual rows exceed its estimate tenfold. What should it read, and how should it compare?

    Choose one answer.

    Show the answer: Question 10 of 10

    Answer: EXPLAIN (ANALYZE, FORMAT JSON), comparing Actual Rows with Plan Rows, both of which are per loop

    The recorded index lookup had Plan Rows 1, Actual Rows 1 and 2,000 loops: a ratio of 1.0, not 2,000.

On your own server

Pick a slow query from your application and run EXPLAIN (ANALYZE, BUFFERS) on it inside BEGIN and ROLLBACK. Write down the node whose actual rows differ most from its estimate, and the node with the most buffers.

Interview questions

Warm-up (fresher): How do EXPLAIN and EXPLAIN ANALYZE differ, and when is EXPLAIN ANALYZE unsafe to run?

EXPLAIN only plans the statement: it prints the tree of plan nodes the planner chose, with each node’s estimated cost in planner units, the rows it expects to return and their width, and it runs nothing. EXPLAIN ANALYZE also executes the statement and adds what really happened at every node: the actual rows, the number of loops, and in PostgreSQL 18 the buffers it touched without asking for them. Because it executes the statement, EXPLAIN ANALYZE of an INSERT, UPDATE or DELETE changes data, takes the same locks and fires the same triggers, so on real data it belongs inside BEGIN … ROLLBACK, or on a copy. On a long-running query it also takes as long as the query itself.

Two questions go further: a plan whose estimate is 100 times too low, and a report that writes gigabytes of temporary files. The module’s question page gives each one a first step and the follow-ups to expect:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A join of orders to customers takes 40 seconds. EXPLAIN ANALYZE shows a Nested Loop whose outer scan estimated 20 rows and returned 2,000,000, and whose inner index scan ran with loops=2000000. Explain what went wrong and how you would fix it without forcing a plan.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Mid to senior

    A nightly report sorts and groups 50 million rows. Its EXPLAIN ANALYZE shows Sort Method external merge with Disk 3.1GB and a HashAggregate with Batches 256. The server has 64 GB of RAM and work_mem = 4MB for everyone. What do you change, and what do you check before and after?

    Model answer, Pro. First step and follow-ups

Flashcards · 5 cards

Recall it, then check

Say the answer to yourself before you look: recalling it is what makes it stick.

  1. What do the costs in EXPLAIN mean?

    EXPLAIN shows the plan with estimated costs, rows and widths. Costs are planner units built from page and row costs, never milliseconds.

  2. How do you read EXPLAIN ANALYZE output?

    It runs the statement and adds actual rows and loops. Read a plan from the deepest node up; each node's total includes the nodes below it, and a node's real output is actual rows times loops.

  3. What buffer counts does EXPLAIN ANALYZE show in PostgreSQL 18?

    Hits came from shared buffers, reads from the operating system, and temp pages from spilled sorts and hashes. PostgreSQL 18 includes these buffer counts by default.

  4. What are the first warning signs in a plan?

    Estimates far from the actual rows, large "Rows Removed by Filter" counts, and sorts or hashes that report disk use.

  5. Which EXPLAIN options answer specific questions?

    GENERIC_PLAN for $1 parameters; SERIALIZE, MEMORY, SETTINGS and WAL for their own costs; FORMAT JSON for programs; and wrap any change you analyse in BEGIN ... ROLLBACK.

Key takeaways

  • EXPLAIN shows the plan with estimated costs, rows and widths; costs are planner units built from page and row costs, never milliseconds.
  • EXPLAIN ANALYZE runs the statement and adds actual rows and loops; a plan is read from the deepest node up, and each node’s total includes the nodes below it.
  • Actual rows are averages per loop, printed with two decimals in PostgreSQL 18, so a node’s real output is rows times loops.
  • PostgreSQL 18 includes buffer counts in EXPLAIN ANALYZE: hits came from shared buffers, reads from the operating system, temp pages from spilled sorts and hashes.
  • The first warning signs are estimates far from the actual rows, large Rows Removed by Filter counts and sorts or hashes that report disk use.
  • Options answer specific questions: GENERIC_PLAN for $1 parameters, SERIALIZE, MEMORY, SETTINGS and WAL for their own costs, FORMAT JSON for programs, and BEGIN … ROLLBACK around any change you analyse.

Terms in this lesson

generic plan
A plan made for a statement with parameters ($1, $2 …) without knowing their values, so that it can be reused for any value. EXPLAIN (GENERIC_PLAN) shows one.
plan node
One step of a query plan, such as a Seq Scan, an Index Scan, a Hash Join or a Sort. It receives rows from the nodes below it and passes its own rows to the node above.
query plan
The tree of steps PostgreSQL's planner chooses to run one statement: scans at the leaves, then joins, aggregates and sorts above them. EXPLAIN prints it, with estimated costs and rows for every step.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.