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 13 – Monitoring, tuning and connection pooling

Finding slow queries: pg_stat_statements

How pg_stat_statements keeps one row per query shape, which of its counters name the cause, and how auto_explain logs the plan of a slow run.

  • Intermediate
  • 20 minutes
  • Examples run with PostgreSQL 18.6 and Python 3.14.8
  • By MySmartCoPilot
Free core, Pro depth: 6 of 20 minutes free
Free: One row for every shape of query, not for every run · Ranking by total time, by mean time and by calls · Plans for the slow runs, logged as they happen · Practise: reading a snapshot of the view. With a pass: 3 in-depth parts and 3 interview answers, about 14 minutes.

What you will learn

  • Install pg_stat_statements and read why one row stands for every run of a query shape
  • Rank statements by total time, by mean time and by calls, and say what each ranking answers
  • Diagnose chatty code, missing indexes and spills from rows, blocks and temporary blocks
  • Log the plans of slow statements with auto_explain at a cost a busy server can carry
  • Measure one change with a repeatable workload and the counters cleared before each run

Before you start

What you’ll learn · Before you start

What you will learn

  • Install pg_stat_statements and read why one row stands for every run of a query shape
  • Rank statements by total time, by mean time and by calls, and say what each ranking answers
  • Diagnose chatty code, missing indexes and spills from rows, blocks and temporary blocks
  • Log the plans of slow statements with auto_explain at a cost a busy server can carry
  • Measure one change with a repeatable workload and the counters cleared before each run

Before you start

On this page

A page that answered in 40 milliseconds now takes two seconds, the server looks busy, and no single statement stands out anywhere. pg_stat_statements answers what the log cannot: it keeps one row per shape of statement, with the calls, the time, the rows and the blocks of every run of that shape added up. Three lookups that differ only in their customer number land on one row, so a flood of one-off statements becomes a list worth ranking.

auto_explain covers the other half. It writes the plan of a statement into the server log once that run passes a duration you choose, so the shape that ranks first can be read as the planner built it. Below you load both on PostgreSQL 18.6, watch five statements become two rows, read three rankings off one run, and look at a logged plan.

One row for every shape of query, not for every run

Both modules need shared memory, so they go into shared_preload_libraries and the server is restarted; the manual is blunt about it, a restart “is needed to add or remove the module”. The view is an extension: A package of SQL objects, such as functions, types, operators and often a C library, that CREATE EXTENSION installs into one database at a chosen version. Each object is recorded as a member, so the whole set is updated or dropped together. More in “Extensions and foreign data wrappers”, created per database with CREATE EXTENSION pg_stat_statements, and it needs query identifiers, which compute_query_id computes at its default of auto. pg_stat_statements counts for the whole server; the extension only gives you the view.

What makes the view readable is normalised query: The text pg_stat_statements keeps for an entry, in which constants that did not affect the match appear as parameter symbols such as $1, and a list of constants is squashed to one element.. The identifier is a hash of the query tree after parse analysis, so constants that do not change the meaning are ignored, and the text kept for the row shows them as $1. Follow one statement through that:

Interactive · stage by stage From one statement to one row of counters Follow one SELECT through parse analysis to the row it adds to, then send the same shape with another constant and watch it land on the same row.
From one statement to one row of counters, step 6 of 6. Showing A client sends SQL, Parse and analyse, Query identifier, Text to keep (route = $1), One row (calls 3). In focus: One row.route = 42A client sends SQLroute = 7Parse and analysea query treeQuery identifiera hash of the treeText to keeproute = $1One rowcalls 3

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

Every step as text
  1. The statement arrives. A client sends one SELECT with the number 7 in it. The server parses it and works out what it means, which gives a query tree with the table, the columns and the constant in their places.
  2. The tree becomes a number. With compute_query_id at its default the server hashes that tree into one 64-bit number, the query identifier. Two statements whose trees differ only in their constants hash to the same number.
  3. Constants become parameters. Because the constant was ignored while matching, the text that is kept shows it as a parameter symbol. The row therefore reads route = $1, and the rest of the text is that of the first statement with this identifier.
  4. The counters go up. The server finds the row for this identifier, or makes it, and adds this run to it: one more call, the time it took, the rows it returned and the blocks it read or found in memory.
  5. The next of that shape. A second client sends the same SELECT with 42 instead of 7. Parse analysis gives the same tree shape, so the same identifier, so the same row: calls 2, and the two times are added together.
  6. One row, every run. A third constant makes it calls 3. The view now answers questions about the shape rather than about one run of it, which is what makes its numbers worth ranking.

The run below loads the module, creates 200,000 deliveries, clears the counters and sends five SELECTs.

Five statements, two rows: constants become parameters

normalise_session.py

"""Loading pg_stat_statements on a throwaway PostgreSQL 18.6 cluster and watching five queries become two rows.

The cluster is started with pg_stat_statements and auto_explain in shared_preload_libraries, because both need shared
memory and are loaded when the postmaster starts. normalise.sql then creates the view with CREATE EXTENSION, loads a
deliveries table, resets the counters and runs five SELECTs that differ only in their constants.
Nothing in the output changes between runs: no time, duration or identifier is printed.
"""

from pg_session import Server

SETTINGS = {
    # pg_stat_statements and auto_explain need shared memory, so the postmaster loads them at start.
    "shared_preload_libraries": "'pg_stat_statements,auto_explain'",
    # The settings this track's examples share, so that two runs print the same bytes.
    "autovacuum": "off",
    "max_parallel_workers_per_gather": "0",
    "jit": "off",
    "timezone": "'UTC'",
    "log_line_prefix": "''",
    "default_statistics_target": "1000",
}

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("normalise.sql", note="loading the view, then five queries that differ only in their constants")

Output

-- loading the view, then five queries that differ only in their constants
$ psql -X -a -f normalise.sql
-- Loading pg_stat_statements, then five queries that differ only in their constants.
SHOW shared_preload_libraries;
    shared_preload_libraries     
---------------------------------
 pg_stat_statements,auto_explain
(1 row)

SHOW compute_query_id;
 compute_query_id 
------------------
 auto
(1 row)

CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION
CREATE TABLE deliveries (id bigint PRIMARY KEY, route int, parcels int);
CREATE TABLE
INSERT INTO deliveries SELECT i, i % 500, 1 + i % 9 FROM generate_series(1, 200000) AS i;
INSERT 0 200000
ANALYZE deliveries;
ANALYZE
SELECT pg_stat_statements_reset() IS NOT NULL AS counters_cleared;
 counters_cleared 
------------------
 t
(1 row)

SELECT count(*) FROM deliveries WHERE route = 7;
 count 
-------
   400
(1 row)

SELECT count(*) FROM deliveries WHERE route = 42;
 count 
-------
   400
(1 row)

SELECT count(*) FROM deliveries WHERE route = 311;
 count 
-------
   400
(1 row)

SELECT count(*) FROM deliveries WHERE route IN (1, 2, 3);
 count 
-------
  1200
(1 row)

SELECT count(*) FROM deliveries WHERE route IN (1, 2, 3, 4, 5);
 count 
-------
  2000
(1 row)

SELECT calls, rows, query
  FROM pg_stat_statements
 WHERE query LIKE 'SELECT count(*) FROM deliveries%'
 ORDER BY query;
 calls | rows |                             query                              
-------+------+----------------------------------------------------------------
     3 |    3 | SELECT count(*) FROM deliveries WHERE route = $1
     2 |    2 | SELECT count(*) FROM deliveries WHERE route IN ($1 /*, ... */)
(2 rows)

SELECT count(*) AS entries, count(DISTINCT queryid) AS query_ids
  FROM pg_stat_statements
 WHERE query LIKE 'SELECT count(*) FROM deliveries WHERE route = %';
 entries | query_ids 
---------+-----------
       1 |         1
(1 row)

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

normalise.sql

-- Loading pg_stat_statements, then five queries that differ only in their constants.
SHOW shared_preload_libraries;
SHOW compute_query_id;
CREATE EXTENSION pg_stat_statements;

CREATE TABLE deliveries (id bigint PRIMARY KEY, route int, parcels int);
INSERT INTO deliveries SELECT i, i % 500, 1 + i % 9 FROM generate_series(1, 200000) AS i;
ANALYZE deliveries;

SELECT pg_stat_statements_reset() IS NOT NULL AS counters_cleared;

SELECT count(*) FROM deliveries WHERE route = 7;
SELECT count(*) FROM deliveries WHERE route = 42;
SELECT count(*) FROM deliveries WHERE route = 311;
SELECT count(*) FROM deliveries WHERE route IN (1, 2, 3);
SELECT count(*) FROM deliveries WHERE route IN (1, 2, 3, 4, 5);

SELECT calls, rows, query
  FROM pg_stat_statements
 WHERE query LIKE 'SELECT count(*) FROM deliveries%'
 ORDER BY query;

SELECT count(*) AS entries, count(DISTINCT queryid) AS query_ids
  FROM pg_stat_statements
 WHERE query LIKE 'SELECT count(*) FROM deliveries WHERE route = %';

Five statements left two rows. The three route counts share the row route = $1, with calls 3 and one query identifier, and the two list queries share route IN ($1 /*, ... */): a list of constants is squashed to one element with that comment as the sign of it. A long normalised text reads better reformatted, which the SQL formatter does without a server.

Key idea

One row per query shape, not per run, which is why a flood of cheap statements can outrank one slow report.

Checkpoint

Check: One row per query shape

  1. Question 1 of 2 The run sent three counts of one route with three different numbers and two counts with lists of three and five routes. How many rows did those five statements leave in the view?

    Type a number.

    A hint

    Two statements share a row when their query trees differ only in constants.

    Show the answer: Question 1 of 2

    Answer: 2

    The three single-route counts became route = $1 with calls 3, and the two list queries became route IN ($1 /, ... /) with calls 2, because a list of constants is squashed to one element.

  2. Question 2 of 2 A server runs PostgreSQL 18 without pg_stat_statements. Which sequence makes the view available for a database?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Add it to shared_preload_libraries, restart the server, then run CREATE EXTENSION in that database

    Preload, restart, create: the run's cluster starts with pg_stat_statements and auto_explain in shared_preload_libraries, and its script then creates the extension in one database.

Ranking by total time, by mean time and by calls

Three rankings answer three questions, and one view gives all of them. Total execution time asks which shape costs the server most work; mean execution time asks which shape a user waits longest for; calls asks which shape the application sends most often. The run puts three workloads on one cluster: 300 lookups and 30 whole-table sums through pgbench, then one large sort.

Three workloads, and the three rankings of the same view

ranking_session.py

"""Three workloads on one cluster, then the three rankings pg_stat_statements answers: total time, mean time, calls.

pgbench runs the two repeated workloads with a fixed number of transactions (-t), one client and a fixed random seed,
so the call counts repeat exactly; psql runs the single large sort once. ranking.sql then reads the view and prints
each entry's calls, rows, blocks and its RANK by total time, by mean time and by calls, never a time: the three
workloads are built to differ by a factor of ten or more, so the ranks are the same on every run, while the
milliseconds of a loaded machine are not. pgbench's own timings are replaced with fixed text for the same reason.
"""

from pg_session import Server

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

# pgbench measures the run, and a measurement is never the same twice: its numbers are shown as fixed text.
PGBENCH = [
    (r"latency average = [0-9.]+ ms", "latency average = <ms> ms"),
    (r"initial connection time = [0-9.]+ ms", "initial connection time = <ms> ms"),
    (r"tps = [0-9.]+ \(without initial connection time\)", "tps = <tps> (without initial connection time)"),
]

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("ranking_setup.sql", note="the table and the counters, cleared")
    pg.shell("pgbench", "-n", "-t", "300", "-c", "1", "--random-seed=7", "-f", "lookup.sql", "postgres",
             mask=PGBENCH, note="300 single-row lookups by primary key")
    pg.shell("pgbench", "-n", "-t", "30", "-c", "1", "--random-seed=7", "-f", "parcels_total.sql", "postgres",
             mask=PGBENCH, note="30 whole-table sums")
    pg.psql("ranking.sql", note="one large sort, then the three rankings")

Output

-- the table and the counters, cleared
$ psql -X -a -f ranking_setup.sql
-- One table of 200,000 deliveries, then the counters cleared so that only the workloads below are counted.
CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION
CREATE TABLE deliveries (id bigint PRIMARY KEY, route int, customer int, parcels int);
CREATE TABLE
INSERT INTO deliveries SELECT i, i % 500, i % 50000, 1 + i % 9 FROM generate_series(1, 200000) AS i;
INSERT 0 200000
ANALYZE deliveries;
ANALYZE
SELECT pg_stat_statements_reset() IS NOT NULL AS counters_cleared;
 counters_cleared 
------------------
 t
(1 row)

-- 300 single-row lookups by primary key
$ pgbench -n -t 300 -c 1 --random-seed=7 -f lookup.sql postgres
pgbench: setting random seed to 7
pgbench (18.6)
transaction type: lookup.sql
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
maximum number of tries: 1
number of transactions per client: 300
number of transactions actually processed: 300/300
number of failed transactions: 0 (0.000%)
latency average = <ms> ms
initial connection time = <ms> ms
tps = <tps> (without initial connection time)

-- 30 whole-table sums
$ pgbench -n -t 30 -c 1 --random-seed=7 -f parcels_total.sql postgres
pgbench: setting random seed to 7
pgbench (18.6)
transaction type: parcels_total.sql
scaling factor: 1
query mode: simple
number of clients: 1
number of threads: 1
maximum number of tries: 1
number of transactions per client: 30
number of transactions actually processed: 30/30
number of failed transactions: 0 (0.000%)
latency average = <ms> ms
initial connection time = <ms> ms
tps = <tps> (without initial connection time)

-- one large sort, then the three rankings
$ psql -X -a -f ranking.sql
-- One large sort, once, and then the view read three ways.
SELECT count(*) FROM (SELECT id, route, parcels FROM deliveries ORDER BY parcels, route, id) AS sorted;
 count  
--------
 200000
(1 row)

SELECT calls,
       rows,
       shared_blks_hit + shared_blks_read AS blocks,
       rank() OVER (ORDER BY total_exec_time DESC) AS by_total_time,
       rank() OVER (ORDER BY mean_exec_time DESC) AS by_mean_time,
       rank() OVER (ORDER BY calls DESC) AS by_calls,
       left(query, 44) AS query
  FROM pg_stat_statements
 WHERE query LIKE '%deliveries%'
 ORDER BY by_total_time;
 calls | rows | blocks | by_total_time | by_mean_time | by_calls |                    query                     
-------+------+--------+---------------+--------------+----------+----------------------------------------------
    30 |   30 |  38220 |             1 |            2 |        2 | SELECT sum(parcels) FROM deliveries WHERE pa
     1 |    1 |   1280 |             2 |            1 |        3 | SELECT count(*) FROM (SELECT id, route, parc
   300 |  300 |   1203 |             3 |            3 |        1 | SELECT parcels FROM deliveries WHERE id = $1
(3 rows)

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

ranking_setup.sql

-- One table of 200,000 deliveries, then the counters cleared so that only the workloads below are counted.
CREATE EXTENSION pg_stat_statements;
CREATE TABLE deliveries (id bigint PRIMARY KEY, route int, customer int, parcels int);
INSERT INTO deliveries SELECT i, i % 500, i % 50000, 1 + i % 9 FROM generate_series(1, 200000) AS i;
ANALYZE deliveries;
SELECT pg_stat_statements_reset() IS NOT NULL AS counters_cleared;

lookup.sql

-- pgbench script: one row by primary key, the shape an application repeats all day.
\set id random(1, 200000)
SELECT parcels FROM deliveries WHERE id = :id;

parcels_total.sql

-- pgbench script: a sum over every row, the shape a dashboard repeats every few seconds.
SELECT sum(parcels) FROM deliveries WHERE parcels > 2;

ranking.sql

-- One large sort, once, and then the view read three ways.
SELECT count(*) FROM (SELECT id, route, parcels FROM deliveries ORDER BY parcels, route, id) AS sorted;

SELECT calls,
       rows,
       shared_blks_hit + shared_blks_read AS blocks,
       rank() OVER (ORDER BY total_exec_time DESC) AS by_total_time,
       rank() OVER (ORDER BY mean_exec_time DESC) AS by_mean_time,
       rank() OVER (ORDER BY calls DESC) AS by_calls,
       left(query, 44) AS query
  FROM pg_stat_statements
 WHERE query LIKE '%deliveries%'
 ORDER BY by_total_time;

Each ranking names a different statement. The dashboard sum ranks first by total time: 30 calls that touched 38,220 blocks between them. The sort ranks first by mean time, on 1 call and 1,280 blocks: nobody runs it often, but whoever does waits. The lookup ranks first by calls, 300 of them for 1,203 blocks, the shape to read when an application feels slow although every statement in it is quick. Blocks are printed instead of milliseconds on purpose: they repeat exactly on every run, while times on a shared machine do not.

Checkpoint

Check: The three rankings

  1. Question 1 of 2 In the recorded run, the one large sort ran once and read 1,280 blocks while the dashboard sum ran 30 times and read 38,220. Which ranking puts the sort first?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Mean execution time

    Total time ranks the load on the server, mean time ranks one user's wait and calls ranks how often an application sends the shape; the run put a different statement first in each.

  2. Question 2 of 2 Why do the lesson's runs print the blocks of each shape and a rank instead of the milliseconds in total_exec_time?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Block counts repeat exactly for the same workload, while times depend on what else the machine is doing

    Both numbers matter, but only one is reproducible: the recorded runs rank by time and print counts, so the page can state a number that holds on every run.

Plans for the slow runs, logged as they happen

A ranking names a shape; it does not say which plan ran. auto_explain closes that gap: loaded with the server, it writes the plan of any run that takes longer than auto_explain.log_min_duration into the server log, with the statement’s own text above it. Its default is -1, which logs nothing, so the module does no work until somebody sets a threshold; 0 logs every statement. This run sets 0 for one session and shows the entry that appeared.

One entry in the log: the query text and the plan under it

autoexplain_session.py

"""auto_explain writing the plan of a statement into the server log, on a throwaway PostgreSQL 18.6 cluster.

auto_explain is loaded with the postmaster, and does nothing until a duration is set, so autoexplain.sql sets
auto_explain.log_min_duration to 0 for its own session: every statement after that has its plan logged. One query
then runs, and the session prints the log entry it produced. The cluster writes its log with an empty
log_line_prefix, so the entry carries no time or process number, and the duration auto_explain reports is replaced
with fixed text, because a measured time is never the same twice.
"""

import re

from pg_session import Server

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

TIMES = [(re.compile(r"duration: [0-9.]+ ms").pattern, "duration: <ms> ms")]

with Server(settings=SETTINGS) as pg:
    pg.up()
    pg.psql("ranking_setup.sql", note="the same 200,000 deliveries, and the counters cleared")
    pg.psql("autoexplain.sql", note="plans logged from this session on")
    pg.run("grep", "-A", "7", "plan:", "data.log", mask=TIMES, note="the entry auto_explain wrote")

Output

-- the same 200,000 deliveries, and the counters cleared
$ psql -X -a -f ranking_setup.sql
-- One table of 200,000 deliveries, then the counters cleared so that only the workloads below are counted.
CREATE EXTENSION pg_stat_statements;
CREATE EXTENSION
CREATE TABLE deliveries (id bigint PRIMARY KEY, route int, customer int, parcels int);
CREATE TABLE
INSERT INTO deliveries SELECT i, i % 500, i % 50000, 1 + i % 9 FROM generate_series(1, 200000) AS i;
INSERT 0 200000
ANALYZE deliveries;
ANALYZE
SELECT pg_stat_statements_reset() IS NOT NULL AS counters_cleared;
 counters_cleared 
------------------
 t
(1 row)

-- plans logged from this session on
$ psql -X -a -f autoexplain.sql
-- auto_explain does nothing until a duration is set; 0 means every statement of this session.
SHOW auto_explain.log_min_duration;
 auto_explain.log_min_duration 
-------------------------------
 -1
(1 row)

SET auto_explain.log_min_duration = 0;
SET
SELECT sum(parcels) FROM deliveries WHERE route = 11;
 sum  
------
 2004
(1 row)

RESET auto_explain.log_min_duration;
RESET

-- the entry auto_explain wrote
$ grep -A 7 plan: data.log
LOG:  duration: <ms> ms  plan:
	Query Text: SELECT sum(parcels) FROM deliveries WHERE route = 11;
	Aggregate  (cost=3775.00..3775.01 rows=1 width=8)
	  ->  Seq Scan on deliveries  (cost=0.00..3774.00 rows=400 width=4)
	        Filter: (route = 11)

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

autoexplain.sql

-- auto_explain does nothing until a duration is set; 0 means every statement of this session.
SHOW auto_explain.log_min_duration;
SET auto_explain.log_min_duration = 0;
SELECT sum(parcels) FROM deliveries WHERE route = 11;
RESET auto_explain.log_min_duration;

ranking_setup.sql

-- One table of 200,000 deliveries, then the counters cleared so that only the workloads below are counted.
CREATE EXTENSION pg_stat_statements;
CREATE TABLE deliveries (id bigint PRIMARY KEY, route int, customer int, parcels int);
INSERT INTO deliveries SELECT i, i % 500, i % 50000, 1 + i % 9 FROM generate_series(1, 200000) AS i;
ANALYZE deliveries;
SELECT pg_stat_statements_reset() IS NOT NULL AS counters_cleared;

The entry holds the query text with its real constant, 11, and the plan as the planner built it: an aggregate over a sequential scan that expected 400 rows. That is enough to recognise a missing index. The three parts below measure the rest: which counters point at chatty code, a missing index or a spill; which auto_explain settings a busy server can afford, and how an entry is matched to its row; and the loop that proves a change helped. Grouping entries by shape is what the log pattern analyzer is for.

Checkpoint

Check: What a logged plan holds

  1. One question auto_explain is preloaded and nothing else is set. What does it write into the log?

    Choose one answer.

    Show the answer: One question

    Answer: Nothing, because log_min_duration is -1 by default

    The module costs nothing until a duration is set; the session in the run sets 0 for itself, which logged the plan of its one statement.

Practise: reading a snapshot of the view

Work these out on the readings above, with a pencil rather than a server.

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 9 An application sends 5,000 lookups that differ only in the customer number they filter by. How many rows of the view do they use?

    Type a number.

    A hint

    The identifier is a hash of the query tree with the constants ignored.

    Show the answer: Question 1 of 9

    Answer: 1

    One row with calls 5,000. That is the whole point of normalisation: the three route counts of the run shared one row with calls 3, and a flood of lookups shares one row you can rank.

  2. Question 2 of 9 After the run, somebody sends the same count for route 500. What changes in the view?

    Choose one answer.

    Show the answer: Question 2 of 9

    Answer: The existing row goes to calls 4, and its text still reads route = $1

    A row describes a shape, and every run of that shape is added to it, which is why its calls, rows and blocks are sums rather than one measurement.

  3. Question 3 of 9 Two statements have exactly the same text, and the view shows them as two rows. Which explanation fits?

    Choose one answer.

    Show the answer: Question 3 of 9

    Answer: They were run with different search_path settings, so parse analysis resolved them to different tables

    The identifier is a function of the resolved tree, which is why the manual warns that identical texts may be separate rows, and that two rows may also merge after a table is dropped and recreated.

  4. Question 4 of 9 In the recorded run, which shape ranks first by total execution time, and what does that ranking tell an operator?

    Choose one answer.

    Show the answer: Question 4 of 9

    Answer: The dashboard sum, with 30 calls and 38,220 blocks: it is where most of the server's work goes

    Rank by total time to spend your effort where the server spends its own; rank by mean time when somebody is waiting, and by calls when the application is chatty.

  5. Question 5 of 9 A shape has calls 1 and ranks first by mean execution time, well below other shapes by total time. Who notices it, and when?

    Choose one answer.

    Show the answer: Question 5 of 9

    Answer: Whoever runs it: one person waits a long time, and the server barely feels the load

    The run's large sort is exactly this case: 1 call and 1,280 blocks, first by mean time and second by total time.

  6. Question 6 of 9 Two readings of a busy server, taken a week apart, show the same shape with a 30 % larger mean time. What is the weakest conclusion to draw from that alone?

    Choose one answer.

    Show the answer: Question 6 of 9

    Answer: That the statement itself got slower, since a mean time mixes in whatever else the server was doing

    Rank by times, confirm with counts: calls, rows and blocks are the parts of the row that mean the same thing on a quiet machine and a loaded one.

  7. Question 7 of 9 Which pair of settings makes auto_explain write the plan of every statement of a session into the log?

    Choose one answer.

    Show the answer: Question 7 of 9

    Answer: Preload the module, then set auto_explain.log_min_duration to 0 in the session

    The run sets 0 for its own session and leaves the server alone, which is how a plan is caught on a server where logging everything would be too much.

  8. Question 8 of 9 The logged entry of the run shows the query text with route = 11 and a sequential scan expecting 400 rows. What can you already say, and what still needs another setting?

    Choose one answer.

    Show the answer: Question 8 of 9

    Answer: The plan and its estimates are there; the counts from the run itself are not

    A planned entry is enough to recognise a whole-table scan where an index was expected; it is not enough to prove a misestimate, which is why the busy-server settings exist.

  9. Question 9 of 9 A support ticket says one page was slow at nine this morning and fine now. Which of the two modules can answer it, and why?

    Choose one answer.

    Show the answer: Question 9 of 9

    Answer: The log, because an entry belongs to the run that produced it, while the view holds sums over a period

    The pair works together: the view ranks shapes over a period, and the log explains single runs, which is why one is read for what to fix and the other for what happened.

Reading the counters that point at the cause Pro: opens with a Pro, Premium or Ultimate pass

A ranking tells you where to look, and the rest of the row tells you what is wrong. Rows against calls, blocks against calls, temporary blocks and the WAL columns each point at a different cause, and PostgreSQL 18 added four things worth knowing about.

In this part:

  • Rows per call and blocks per call name the shape of the waste
  • Spills, writes and what the WAL columns add
  • What PostgreSQL 18 added, and when entries get dropped

3 runnable examples (counters.sql, counters_session.py, limits_count.sql, limits_generate.sql, limits_session.py, limits_setup.sql, one_row.sql, pg18.sql, pg18_session.py) · about 9 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Logging slow plans on a busy server, and matching them back Pro: opens with a Pro, Premium or Ultimate pass

auto_explain can tell you almost everything EXPLAIN ANALYZE would, and charge you for it on every statement the server runs. The settings below choose how much evidence each entry carries, and the last one links that entry back to the row that made you look.

In this part:

  • Actual rows without the cost of reading the clock
  • Sampling, nested statements and the link back to the view

1 runnable example (busy.sql, busy_link.sql, busy_session.py) · about 8 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

The loop that proves a change helped, with numbers Pro: opens with a Pro, Premium or Ultimate pass

Every change made on a busy server is a claim, and the view can settle it. The loop is always the same: clear the counters, run a workload you can repeat, read the row, change one thing, and do it again.

In this part:

  • Clearing the counters and running a workload you can repeat
  • The same workload after one index, compared by blocks
  • What to keep from these three parts

1 diagram · 1 runnable example (by_customer.sql, loop_after.sql, loop_before.sql, loop_session.py) · about 7 minutes

Opening this part with your pass…

This part opens with any MySmartCoPilot pass.

  • Pro pass · 1 week₹79$2.99
  • 30 days₹179$5.99
  • 1 year₹1,699$45

One payment; a pass never renews. The price shown is the total you pay: no fees or charges are added at checkout.

It also opens every other in-depth part in Learn and every Pro tool. Sales are final, except where the law gives you a right to withdraw, for example in the EU and the UK (refund policy).

Interview questions

Three situations that start from this view: a report nobody can find, a server where plan logging became the problem, and a change somebody must defend. Decide your own opening move, then read the first step on its page.

  1. Mid to senior

    A checkout page takes four seconds. One entry shows 48,000 calls, 48,300 rows and nine blocks a call; another shows 12 calls, a mean of two seconds and the whole table read on every call. Which do you work on first, and how do you decide?

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

  2. Senior to staff

    A team switched plan logging on with row counts to catch a slow report. Every statement on a server that runs 3,000 a second became slower, including statements that were never logged. Explain the cause and give the settings you would run instead, with what each costs.

    Model answer, Pro. First step and follow-ups

  3. Mid to senior

    A colleague says a new index made a report twice as quick, and offers a reading of the statistics view taken an hour after the index was created. What is wrong with that evidence, and how would you produce evidence that holds on a server other people are using?

    Model answer, Pro. First step and follow-ups

Flashcards · 4 cards

Recall it, then check

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

  1. What is pg_stat_statements, and what does it need?

    An extension created per database that exposes a view, while the counting itself is server-wide. It needs compute_query_id (auto by default), and adding or removing the module needs a restart because it loads from shared_preload_libraries.

  2. How does pg_stat_statements group statements?

    By normalisation: one row per query shape, identified by a hash of the query tree after parse analysis, with constants shown as $1 and a list of constants squashed to one element. So a flood of cheap statements can outrank one slow report.

  3. What three questions do the rankings of pg_stat_statements answer?

    Total execution time (which shape costs the server most work), mean execution time (which shape a user waits longest for), and calls (which shape the application sends most often). Blocks are shown instead of milliseconds because they repeat exactly between runs.

  4. What does auto_explain do?

    Loaded with the server, it writes the plan of any run slower than auto_explain.log_min_duration into the server log, with the statement text above it. The default -1 logs nothing; 0 logs every statement.

Key takeaways

  • pg_stat_statements loads with a server restart and keeps one row per shape of statement, with constants as $1.
  • Total time ranks the load, mean time one user’s wait, calls the chatty code: the run put a different statement first in each ranking.
  • Block counts repeat exactly from run to run while times on a shared machine do not, so a ranking read in blocks can be compared between runs.
  • auto_explain writes the plan of any run slower than auto_explain.log_min_duration into the log, under the statement’s own text; at its default of -1 it logs nothing.

Terms in this lesson

N+1 query
The pattern where an application fetches a list and then sends one more statement for each row of it, so one page turns into hundreds of small statements instead of one.
normalised query
The text pg_stat_statements keeps for an entry, in which constants that did not affect the match appear as parameter symbols such as $1, and a list of constants is squashed to one element.
query identifier
A 64-bit hash of a statement's query tree after parse analysis, computed when compute_query_id is on or auto. pg_stat_statements uses it as the identity of an entry, so statements that differ only in their constants share one row.
shared_preload_libraries
The server setting that names the libraries the postmaster loads when it starts. Modules that need shared memory, such as pg_stat_statements, are listed there, and adding or removing one needs a server restart.

References

Related tools

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.