PostgreSQL 17
The statements view renamed its I/O timers
For anyone who ranks slow statements by the time they spend waiting on blocks.
Reference page, revised in place. Last updated .
Two timers that never said whose buffers they were
The statements extension has counted block I/O against three kinds of buffer for a long time: shared buffers, which every backend can see; local buffers, which belong to one session and hold its temporary tables; and temporary files, which are not buffers at all. The counts were always named for their scope. shared_blks_read, local_blks_hit, temp_blks_written leave no room for doubt about what is being counted.
The timers were not. Two columns called blk_read_time and blk_write_time carried the time spent on shared-buffer I/O only, and the name said nothing about that. Local-buffer time was not reported anywhere, so a session doing heavy work in a temporary table showed a rising block count and a flat timer, and nobody could tell whether that was the absence of a measurement or the absence of a cost.
PostgreSQL 17 closed the gap in the obvious way. The two timers took the scope into their names, becoming shared_blk_read_time and shared_blk_write_time, and a matching pair arrived for local buffers. The release notes record the rename under Migration to Version 17 in the PostgreSQL 17 release notes, which is the half of the notes that lists what will not carry on working, and the new local-buffer columns separately under the extension’s own heading.
Ranking by read time on 16, then on 17
The fixture is a small order table on both servers, queried twice so the extension has something with a nonzero call count to rank.
CREATE EXTENSION pg_stat_statements;
CREATE TABLE order_header (order_id bigint PRIMARY KEY, placed_at timestamptz, region text);
INSERT INTO order_header
SELECT g, now() - (g || ' minutes')::interval, 'region-' || (g % 7)
FROM generate_series(1, 40000) AS g;
SELECT count(*) FROM order_header WHERE region = 'region-3';
SELECT count(*) FROM order_header WHERE placed_at > now() - interval '1 day';
CREATE EXTENSION
CREATE TABLE
INSERT 0 40000
count
-------
5714
(1 row)
count
-------
1439
(1 row)
On 16, the shape every review query has is this one: order the statements by the time they spent waiting on blocks.
SELECT substr(query, 1, 44) AS statement,
calls,
round(blk_read_time::numeric, 2) AS read_ms,
round(blk_write_time::numeric, 2) AS write_ms
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query LIKE 'SELECT count(*) FROM order_header%'
ORDER BY blk_read_time DESC;
statement | calls | read_ms | write_ms
----------------------------------------------+-------+---------+----------
SELECT count(*) FROM order_header WHERE regi | 1 | 0.00 | 0.00
SELECT count(*) FROM order_header WHERE plac | 1 | 0.00 | 0.00
(2 rows)
Send the same text to a 17 server and it stops before it reads a row.
SELECT substr(query, 1, 44) AS statement,
calls,
round(blk_read_time::numeric, 2) AS read_ms,
round(blk_write_time::numeric, 2) AS write_ms
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query LIKE 'SELECT count(*) FROM order_header%'
ORDER BY blk_read_time DESC;
ERROR: column "blk_read_time" does not exist
LINE 3: round(blk_read_time::numeric, 2) AS read_ms,
^
The error names the column in the select list rather than the one in the ORDER BY, because the parser resolves the target list first. Both are gone, and fixing only the one the message names gets you a second error and a second deploy.
On 17 the same numbers are there under the scoped names, with the local pair beside them.
SELECT substr(query, 1, 44) AS statement,
calls,
round(shared_blk_read_time::numeric, 2) AS shared_read_ms,
round(shared_blk_write_time::numeric, 2) AS shared_write_ms,
round(local_blk_read_time::numeric, 2) AS local_read_ms
FROM pg_stat_statements
WHERE dbid = (SELECT oid FROM pg_database WHERE datname = current_database())
AND query LIKE 'SELECT count(*) FROM order_header%'
ORDER BY shared_blk_read_time DESC;
statement | calls | shared_read_ms | shared_write_ms | local_read_ms
----------------------------------------------+-------+----------------+-----------------+---------------
SELECT count(*) FROM order_header WHERE regi | 1 | 0.00 | 0.00 | 0.00
SELECT count(*) FROM order_header WHERE plac | 1 | 0.00 | 0.00 | 0.00
(2 rows)
Every number is zero on a fixture this small, and on a server where track_io_timing is off every number is zero at any size. That is worth saying because a zero here is ambiguous in a way the counts are not: it means either no physical I/O happened or nobody asked the server to time it, and the two look identical in a dashboard.
The queries to grep for before the upgrade
The rename is not confined to the review query somebody runs by hand. Anything that reads the extension reads these columns, and the ones that hurt are the ones nobody remembers writing.
- A scrape job that selects the whole row and posts it somewhere.
SELECT *keeps working on 17 and starts emitting two differently named fields, so the collector does not fail, the series simply stops. - A materialised snapshot table built with
CREATE TABLE ... AS SELECTfrom the view. The insert into it fails on 17 with a column mismatch rather than a missing column, which reads as a schema problem in your own code. - A version-pinned exporter query. If the collection layer cannot branch on the server version, two jobs with two rule files is the honest fallback.
The safe shape is to select the columns by name and alias them to one stable name on the way out, so the branch lives in one query rather than in every rule that consumes it.
Alerting on a timer that changed its name
The rule worth keeping is unchanged in meaning: the share of a statement’s total time that is spent waiting for blocks rather than executing. What changes is the numerator.
- Shared block read time as a fraction of total execution time, per statement, over a window. A statement whose read share climbs while its call count is flat has lost a cache it used to have, and that is a different problem from a plan change.
- Local block read time, which is new and has no history. Treat it as a graph first. A nonzero and rising value points at temporary-table work that used to be invisible.
Neither number means anything without track_io_timing, and a rule written against a server where it is off will sit at zero forever without ever firing or erroring.
What switching the timing on costs
The columns exist whether or not timing is enabled; they are zero when it is not. Turning track_io_timing on asks the operating system for a clock reading around every read and write, and the overhead depends entirely on how fast that clock is on your host. pg_test_timing ships with the server and answers the question directly, which is worth more than an opinion. The setting takes effect without a restart and can be withdrawn without one.
The extension itself needs shared_preload_libraries, so it needs a restart if it is not already loaded, and it keeps a fixed number of entries with the least-used ones evicted. Neither of those changed in 17. What changed is one line of every query anyone wrote against it.