Skip to content
dbexplore

PostgreSQL 17

Knowing how old a statements row really is

For anyone computing a rate from pg_stat_statements without knowing the denominator.

Reference page, revised in place. Last updated .

Every row in one table, and no two of them the same age

The counters in the statements extension are cumulative, which everyone knows, and the usual advice is to take two snapshots and subtract. That advice hides an assumption: that every row in the view has been accumulating over the same period. It has not. A row is created the first time a statement is executed after the last reset, and entries are evicted when the table is full, so a statement first seen ten minutes ago sits in the same result set as one that has been counting since the server started. Divide either by the same elapsed time and one of the two answers is wrong.

Before 17 the view offered nothing to correct for this. The only timestamp in the extension was in a separate one-row view recording when the whole thing was last reset, and a rate computed from it was right for the oldest rows and an underestimate for every row younger than that. Eviction made it worse, because an entry that is evicted and then recreated looks exactly like an entry that has been there all along.

PostgreSQL 17 added two timestamps per row. stats_since is when that row started counting, and minmax_stats_since is when its minimum and maximum execution times were last cleared. The second exists because of a related change in the same release: the reset function took a fourth argument, minmax_only, which throws away the two extreme values without touching the totals. The pair is recorded under pg_stat_statements in the PostgreSQL 17 release notes.

Asking a 16 server for a timestamp it does not have

The fixture is a price table and two statements against it, so there is something with a history to inspect.

CREATE EXTENSION pg_stat_statements;
CREATE TABLE price_book (sku text PRIMARY KEY, currency text, unit_price numeric);
INSERT INTO price_book
SELECT 'sku-' || g, CASE WHEN g % 3 = 0 THEN 'EUR' ELSE 'GBP' END, (g % 900) / 10.0
FROM generate_series(1, 25000) AS g;
SELECT count(*) FROM price_book WHERE currency = 'EUR';
SELECT max(unit_price) FROM price_book WHERE sku LIKE 'sku-1%';
CREATE EXTENSION
CREATE TABLE
INSERT 0 25000
 count 
-------
  8333
(1 row)

         max         
---------------------
 89.9000000000000000
(1 row)

On 16 the column is simply not there, and a collector that started asking for it after an upgrade of its own fails against the older half of the fleet.

SELECT substr(query, 1, 40) AS statement, calls, stats_since
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
  AND query LIKE '%price_book%';
ERROR:  column "stats_since" does not exist
LINE 1: SELECT substr(query, 1, 40) AS statement, calls, stats_since
                                                         ^

The nearest thing 16 offers is the extension-wide reset time, which is one value for every row in the view.

SELECT stats_reset FROM pg_stat_statements_info;
          stats_reset          
-------------------------------
 2026-09-17 16:37:02.216958+00
(1 row)

On 17 the age is per row, and so is the age of the extremes.

SELECT substr(query, 1, 40) AS statement,
       calls,
       round(min_exec_time::numeric, 2) AS min_ms,
       round(max_exec_time::numeric, 2) AS max_ms,
       stats_since = minmax_stats_since AS never_separately_reset
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
  AND query LIKE 'SELECT %price_book%'
ORDER BY statement;
                statement                 | calls | min_ms | max_ms | never_separately_reset 
------------------------------------------+-------+--------+--------+------------------------
 SELECT count(*) FROM price_book WHERE cu |     1 |   4.11 |   4.11 | t
 SELECT max(unit_price) FROM price_book W |     1 |   6.55 |   6.55 | t
(2 rows)

Clearing the extremes without losing the totals

The reason those two timestamps are separate is the fourth argument. A minimum and a maximum are not averages: once a statement has had one catastrophic execution, its maximum is that execution forever, and the row stops being able to tell you about today. Clearing the whole extension to get rid of it also throws away every total, every count and every row’s history.

SELECT pg_stat_statements_reset(0, 0, 0, true) AS reset_at;
           reset_at            
-------------------------------
 2026-09-17 16:37:17.756745+00
(1 row)

The function returns the moment it acted, which is new in 17 as well; before that it returned nothing. Reading the rows again shows what survived.

SELECT substr(query, 1, 40) AS statement,
       calls,
       round(min_exec_time::numeric, 2) AS min_ms,
       round(max_exec_time::numeric, 2) AS max_ms,
       minmax_stats_since > stats_since AS extremes_cleared_later
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
  AND query LIKE 'SELECT %price_book%'
ORDER BY statement;
                statement                 | calls | min_ms | max_ms | extremes_cleared_later 
------------------------------------------+-------+--------+--------+------------------------
 SELECT count(*) FROM price_book WHERE cu |     1 |   0.00 |   0.00 | t
 SELECT max(unit_price) FROM price_book W |     1 |   0.00 |   0.00 | t
(2 rows)

The call counts and the totals are untouched and the two extremes are back to zero, waiting for the next execution to set them. On 16 there is no way to ask for that.

SELECT pg_stat_statements_reset(0, 0, 0, true);
ERROR:  function pg_stat_statements_reset(integer, integer, integer, boolean) does not exist
LINE 1: SELECT pg_stat_statements_reset(0, 0, 0, true);
               ^
HINT:  No function matches the given name and argument types. You might need to add explicit type casts.

The hint about explicit type casts is misleading here. There is no cast that helps; the three-argument form is all 16 has.

The rate that was wrong, and how to make it right

The practical use of stats_since is in the denominator. A per-second rate for a row should divide by the time since that row started counting, not by the time since the last reset, and on 17 that is one expression rather than an assumption.

Two rules become writable that were not before. First, alert on statements whose own window is long enough to trust, by discarding rows younger than the window you are measuring; a statement first seen ninety seconds ago will always look like it appeared from nowhere. Second, watch for rows whose stats_since moves without anybody running a reset, because that is eviction, and eviction means the extension’s table is too small for the workload and the statements you most want to see are the ones being thrown out.

Neither needs a new threshold. They are the same rules you already have, computed over a window the server now tells you rather than one you assumed.

What the timestamps cost

Nothing that the extension was not already paying. Two timestamps per entry is a fixed addition to a fixed-size table, and neither column is computed at query time. The extension’s own overhead is unchanged: it is loaded through shared_preload_libraries, which means a restart to add it, and its table holds pg_stat_statements.max entries with the least-used evicted when it fills.

That last setting is worth revisiting once you can see eviction rather than infer it. A cluster whose statement identities churn quickly will hold nothing useful at the default, and on 17 the evidence for raising it is a column rather than an argument.

Put every Postgres you run on autopilot.

We onboard teams in small batches. Tell us about your fleet and we will reach out when a seat opens. One email, no drip campaign.