Skip to content
dbexplore

PostgreSQL 18 moved the WAL I/O counters

pg_stat_wal lost its write and sync columns to pg_stat_io in 18. Nothing errors. The numbers just stop arriving, and most monitoring never notices.

Upgrade a cluster to PostgreSQL 18, open the dashboard that shows how much time the write-ahead log spends writing and syncing, and the line is flat. Not spiky, not wrong. Flat, at zero, since the moment of the upgrade.

Nothing threw an error. The query still runs. It just returns nothing useful, because the columns it reads are no longer there.

What actually changed

pg_stat_wal used to carry the timing and volume of write-ahead log I/O: how many writes, how many syncs, and how long each spent. In 18 those moved into pg_stat_io, which is now the single place all I/O is accounted for, WAL included.

The move is the right call. Before it, WAL I/O lived in one view and everything else lived in another, with different shapes and different reset semantics, and anyone trying to answer “where is this cluster spending its I/O time” had to join two mental models. Now there is one table with a row per backend type, per I/O object, per context, and WAL is just another object in it.

pg_stat_io also changed shape underneath. The old op_bytes column, which told you the unit size so you could multiply it by the operation count, is gone. In its place are read_bytes, write_bytes and extend_bytes, which report the bytes directly. That is better, because op_bytes was never a reliable multiplier once an operation could move more than one block.

The upshot for anyone with a dashboard: two views changed, one lost columns, and the replacement reports in different units.

Why it is silent

This is the part worth internalising, because it generalises past this one release.

A monitoring query that selects a column which no longer exists throws an error, and an error gets noticed. A monitoring query that selects from a view which still exists, with columns that still exist, but where the rows carrying your data have moved elsewhere, returns an empty result. An empty result is indistinguishable from a healthy, quiet cluster.

So the failure mode is not a broken dashboard. It is a dashboard that confidently reports that your write-ahead log is doing no work, on a database that is doing plenty.

We found this the way most people will: not from an alert, but from a chart that looked too calm for the workload underneath it.

The other thing that moved, quietly

While you are in there, query identifiers changed too.

PostgreSQL computes a query identifier by normalising the parse tree and hashing it, which is what lets pg_stat_statements group every execution of the same statement together. In 18, the normalisation of constant lists changed: a list of constants is now jumbled using only its first and last element rather than every element.

The effect is that IN (1, 2, 3) and IN (1, 2, 3, 4, 5) can now collapse to the same identifier where previously they did not. That is a deliberate improvement, because those are the same query with different cardinality and they were fragmenting statistics badly.

It also means query identifiers are not stable across the upgrade. Anything keyed on them, and plan-regression baselines are the obvious case, will see the old identity disappear and a new one arrive with no history. The queries did not change. Their names did.

What 18 gives you in return

It would be unfair to frame this as pure breakage, because 18 is the largest expansion of the observability surface in years.

Per-backend I/O and WAL accounting arrived, so for the first time you can attribute I/O and write-ahead log work to an individual backend without sampling. Vacuum gained cumulative timing per table, and separately a setting that exposes how much time autovacuum spends deliberately throttled rather than working. That distinction matters more than people expect: an autovacuum that is running constantly but spending most of its time asleep under the cost delay is a very different problem from one that is running constantly because there is genuinely that much to do, and until now you could not tell them apart from the catalog.

There is also a new view exposing in-flight asynchronous I/O, alongside a setting that selects how I/O is issued. Most installations are still on the default worker-based method without knowing there is a choice.

And pg_upgrade can now carry optimizer statistics across the major version boundary, which removes the oldest and most avoidable post-upgrade cliff, where a freshly upgraded database picks terrible plans for an hour because nothing has been analysed yet.

What to do about it

Four things, in the order they will bite.

  • Grep your monitoring for pg_stat_wal and check whether anything reads the write or sync columns. If it does, it has been returning nothing since your first 18 cluster.
  • Anywhere you multiplied an operation count by op_bytes, switch to the byte columns and delete the multiplication.
  • Treat query identifiers as discontinuous at the 18 boundary. If you keep performance baselines, snapshot the old ones before you upgrade, and expect a gap rather than a regression.
  • Turn on the cost-delay timing and look at the throttled ratio on your busiest table. It is usually the most surprising number in the release.

The full list of what moved in 18, with the replacement query for each, is kept up to date in what PostgreSQL 18 changed in monitoring.

The version-by-version pages go further than that list does: they show the queries running on both servers, which is how we found that clearing the log counters on 18 now leaves half of them untouched.

The wider lesson is that a monitoring stack with version-specific queries hardcoded into it accumulates silent blind spots with every major release, and they do not announce themselves. The queries that break loudly get fixed the same day. The ones that quietly return nothing can sit for a year, and you only find them when somebody asks why a chart has been flat since spring.

Keep reading

· 5 min read

The pgvector index nobody measured

Vector search in Postgres degrades quietly. The index crosses RAM, the build runs on disk, recall decays with churn, and none of it raises an alert.

Read the post →

· 5 min read

Aurora's writer endpoint can lie to you

CloudWatch measures instances. Your application talks to endpoints. For the minute those disagree, every instance metric looks healthy while writes fail.

Read the post →

· 5 min read

75% of our query-plan storage was duplicate

Thousands of stored plans turned out to be a few hundred distinct shapes. What a structural fingerprint keeps, what it strips, and why regressions depend on it.

Read the post →

Run this on your own fleet.

DBExplore watches every Postgres you run and acts only inside the guardrails you set. Early access is open in small batches.