PostgreSQL 17
pg_stat_checkpointer replaced pg_stat_bgwriter
For anyone who runs an exporter, a Datadog check or a collector against a cluster that is about to become a PostgreSQL 17 cluster.
Reference page, revised in place. Last updated .
A view nobody had needed to re-read since 9.2
The background writer view had been the place checkpoint activity lived for so long that most of the queries reading it predate the people running them. It accumulated an odd mixture: two counters about the checkpointer process, two about the background writer, two timers, one allocation counter, and two columns that counted what ordinary backends did when neither of those processes got there first. Four separate subsystems reporting through one row, because that is where there happened to be room.
PostgreSQL 17 separated them. The checkpointer’s counters moved to a view of their own and were renamed on the way, so checkpoints_timed and checkpoints_req became num_timed and num_requested, and buffers_checkpoint became buffers_written. Restart-point counters, which a standby had previously had no way to see at all, were added beside them. What stayed behind in the old view is the background writer’s own work and the cluster-wide buffer allocation count.
Two columns did not move anywhere. buffers_backend and buffers_backend_fsync were deleted, on the grounds that the same activity is reported more precisely by the I/O statistics view that PostgreSQL 16 added. That is a fair argument and it is also the part that costs an upgrading operator the most, because those two columns are not renamed anywhere and no single column replaces them.
The release notes record the new view under the Monitoring section of the PostgreSQL 17 release notes, and the deletion separately under Migration to Version 17, which is the section worth reading twice: it is the list of things that will not simply carry on working.
The same four counters, asked of 16 and then of 17
The fixture is the same on both servers. A table, twenty thousand rows of padding, and a forced checkpoint so the counters have something in them.
CREATE TABLE checkpoint_probe (id integer PRIMARY KEY, payload text);
INSERT INTO checkpoint_probe SELECT g, repeat('x', 120) FROM generate_series(1, 20000) AS g;
CHECKPOINT;
CREATE TABLE
INSERT 0 20000
CHECKPOINT
On 16, the query that every checkpoint dashboard is built from answers normally.
SELECT checkpoints_timed,
checkpoints_req,
buffers_checkpoint,
buffers_backend,
buffers_backend_fsync
FROM pg_stat_bgwriter;
checkpoints_timed | checkpoints_req | buffers_checkpoint | buffers_backend | buffers_backend_fsync
-------------------+-----------------+--------------------+-----------------+-----------------------
0 | 2 | 2846 | 1602 | 0
(1 row)
On 17, the same text produces this, and this is what your scrape log will contain:
SELECT checkpoints_timed,
checkpoints_req,
buffers_checkpoint,
buffers_backend,
buffers_backend_fsync
FROM pg_stat_bgwriter;
ERROR: column "checkpoints_timed" does not exist
LINE 1: SELECT checkpoints_timed,
^
Both servers are minutes old here, so every checkpoint either of them has taken was requested rather than scheduled and the timed counter has had no chance to move. That is the fixture being honest rather than the counter being broken, and it is worth knowing before you read a zero on a cluster you have just restarted.
Note which column it names. The view still exists on 17, so a query selecting from it does not fail with a missing relation; it fails on the first column in the list that is gone. That detail matters when you are reading someone else’s stack trace, because the column named in the error is an accident of ordering rather than the only column that moved.
The counters themselves are all present on 17, under their new names and in the new view.
SELECT num_timed,
num_requested,
buffers_written,
round((write_time / 1000)::numeric, 1) AS write_seconds,
round((sync_time / 1000)::numeric, 1) AS sync_seconds
FROM pg_stat_checkpointer;
num_timed | num_requested | buffers_written | write_seconds | sync_seconds
-----------+---------------+-----------------+---------------+--------------
0 | 2 | 1367 | 0.0 | 0.0
(1 row)
The two counts of buffers written are not the same number on the two servers, which is the other thing to expect: these are counters over the life of each cluster, not measurements of the fixture.
And the old view survives on 17 with what is genuinely the background writer’s own business, which is worth seeing because it is why a naive SELECT * against it on 17 succeeds and returns something that looks plausible.
SELECT buffers_clean, maxwritten_clean, buffers_alloc FROM pg_stat_bgwriter;
buffers_clean | maxwritten_clean | buffers_alloc
---------------+------------------+---------------
0 | 0 | 2257
(1 row)
The counters a standby never had
The split brought something with it that is easy to miss because it is not a rename. A standby does not take checkpoints; it takes restart points, which do the same work at a point the primary already chose. Before 17 there was no counter for them anywhere, so the question “is this replica’s recovery keeping up with its own flushing” had to be answered from log lines and file timestamps. The new view carries three columns for them, counting restart points that were scheduled, ones that were requested, and ones that actually completed, and the third is the interesting one: a restart point can be skipped when the recovery position has not moved far enough, so requested and completed are genuinely different numbers rather than two names for one event.
On a primary those three columns are zero and stay zero, which is a small annoyance for a fleet dashboard and a large convenience for a replica one. If you have ever wanted to alert on a standby whose restart points are being skipped, 17 is the first version where you can.
Where the deleted columns went
buffers_backend counted buffers that an ordinary backend had to write itself because no auxiliary process had cleaned them first. The replacement is not a column, it is a row: on 17 you read the same activity out of the I/O statistics view, filtered to client backends writing in the normal context. The numbers will not match the old column exactly, because the two count at different layers and the new one separates contexts the old one lumped together, so treat the change as a break in the series rather than a continuation of it. Reset the baseline, do not splice it.
buffers_backend_fsync counted the worse case, where a backend had to perform the fsync itself because the checkpointer’s queue was full. The I/O view has an fsync column with the same meaning and better attribution. In practice both numbers are almost always zero, and the alert worth keeping is the one that fires when they stop being zero.
What your exporter actually does when the column is gone
This is the change that broke integrations in the field rather than in theory. Datadog’s Postgres integration carried an issue about exactly this from February 2025, titled after the symptom: the column changes break metrics collection. The OpenTelemetry collector’s Postgres receiver had the same report two months earlier, filed as background writer stats simply not being scrapeable from a 17 cluster. Both are closed now. Neither was closed before somebody noticed a blank panel.
The failure is worth being precise about, because “it errors” and “you find out” are not the same sentence. We pointed a widely deployed Prometheus exporter at a 16.15 server and at a 17.11 server and read what came out of each. Against 16.15 it published a series for each column of that view, pg_stat_bgwriter_checkpoints_timed_total and pg_stat_bgwriter_buffers_backend_total among them. Against 17.11 it published none of them, and its log carried one line per scrape:
level=error msg="collector failed" name=stat_bgwriter err="pq: column \"checkpoints_timed\" does not exist"
Meanwhile pg_up stayed at 1 and the exporter’s own scrape-error counter stayed at 0, because one collector failing is not the same thing as the scrape failing. So the loud error lands in a log file nobody alerts on, the metric simply stops arriving, and every alert rule written as a rate over a counter evaluates against no data. A rule that fires on a high rate does not fire on absent data unless you wrote it to. That is the practical shape of this break: an error at the database, silence at the dashboard.
The fix is a version-conditional query rather than a rename, because the two names have to coexist across a fleet that is mid-upgrade. Ask the server which it is first, then select the matching column list. If your collection layer cannot branch on version, the honest alternative is two scrape jobs with two rule files, which is uglier and at least fails visibly.
What to alert on once the names have moved
- Checkpoints that were requested rather than scheduled, as a share of all checkpoints:
num_requestedovernum_timed + num_requestedon 17, the same ratio fromcheckpoints_reqandcheckpoints_timedbefore it. A sustained share above roughly a quarter says the write-ahead log budget is forcing checkpoints the schedule did not ask for. The terms are defined here. - Checkpoint write time per checkpoint, from
write_timedivided by the checkpoint count. The absolute number is meaningless across clusters and its change on one cluster is not. - Backend writes, which no longer exist as a column. Read them from the I/O statistics view instead, filtered to the client backend rows, and accept that the two are not numerically identical because they count at different layers.
Leave maxwritten_clean where it is on a graph and off your alert rules. It rises for reasons that are usually a consequence of something else you are already paging on.
What it costs to collect
Nothing gates either view. There is no track_* setting to switch on, no restart, and no extension. Both are a single row assembled from counters the server increments regardless of whether anyone is reading, and the cost of the query is the cost of a round trip.
The one thing that does change, and that has caught people, is the statistics reset function, which takes a target name. On 17 the checkpointer is its own target, so resetting the background writer’s statistics no longer resets the checkpoint counters along with them. If you have a nightly job that resets one, check what it is actually clearing on 17 before you trust a ratio computed over the period since. The failure here is quiet in the same way the missing metric is: the numbers keep arriving, they are simply measured over a window that is not the one you think.
Before the upgrade, the cheapest useful thing you can do is grep. The columns listed at the top of this page are the strings to look for, and they turn up in more places than the exporter: dashboard definitions, alert rule files, the odd cron job that emails a report, and the query somebody pasted into a runbook three years ago. Finding them takes an afternoon. Finding them after the upgrade takes an incident.