Skip to content
dbexplore

Version 18

What PostgreSQL 18 changed in monitoring

For anyone about to upgrade who would rather find the broken panels before the first incident on the new version.

The views, columns and settings that moved in 18, what each replacement query looks like, and which of your existing dashboards will go quietly blank.

Reference page, revised in place. Last updated .

Read this as a migration list

PostgreSQL 18 widened the observability surface more than any release in years, and it moved several things while doing it. A moved column that no longer exists produces an error somebody fixes the same day. A moved row, where the view and the columns both still exist but your data is now somewhere else, produces an empty result, and an empty result looks exactly like a quiet cluster.

What follows is organised by the thing you query, not by the release notes, so it can be worked through against an existing monitoring configuration.

Write-ahead log I/O left pg_stat_wal

pg_stat_wal in 18 carries wal_records, wal_fpi, wal_bytes, wal_buffers_full and stats_reset. That is the whole view. The counters describing how much time was spent writing and syncing the log are not there any more; write-ahead log activity is now accounted for in pg_stat_io alongside everything else, as rows whose object is wal.

SELECT backend_type, context,
       writes, write_bytes, write_time,
       reads,  read_bytes,  read_time,
       fsyncs, fsync_time
FROM pg_stat_io
WHERE object = 'wal'
ORDER BY write_bytes DESC;

The timing columns are zero unless timing is being collected, and 18 keeps two separate switches for that: track_io_timing governs everything whose object is not wal, and track_wal_io_timing governs the rows above. Turning on one and expecting both is a common way to conclude that the new accounting is broken.

While you are in pg_stat_io, note the other change. The op_bytes column is gone. It reported the unit size of an operation, and the convention was to multiply it by the operation count to get a volume, which stopped being reliable once a single operation could move more than one block. In its place are read_bytes, write_bytes and extend_bytes, which report volume directly. Any query that multiplies by op_bytes needs rewriting, and unlike the case above, that one errors rather than going silent.

The values you can match on are worth having to hand: object is relation, temp relation or wal, and context is normal, init, vacuum, bulkread or bulkwrite.

Vacuum now says how long it took

pg_stat_all_tables gained four cumulative timing columns: total_vacuum_time, total_autovacuum_time, total_analyze_time and total_autoanalyze_time. Before 18 the counters told you how many times a table had been vacuumed and when it last happened, and nothing about the cost, which made it impossible to rank maintenance work by expense.

SELECT relid::regclass                                              AS table_name,
       autovacuum_count,
       total_autovacuum_time,
       round((total_autovacuum_time
              / nullif(autovacuum_count, 0))::numeric, 1)           AS avg_ms_per_run,
       total_autoanalyze_time
FROM pg_stat_all_tables
WHERE autovacuum_count > 0
ORDER BY total_autovacuum_time DESC
LIMIT 20;

Separately, and more interesting, vacuum and analyze now report how much of their run was spent deliberately asleep under the cost delay. pg_stat_progress_vacuum and pg_stat_progress_analyze both gained a delay_time column, the server log carries the same figure, and both are gated on track_cost_delay_timing. The distinction this unlocks is between a vacuum that never finishes because there is genuinely that much work and one that never finishes because it is throttled into the ground, and those want opposite changes. There is more on using it in autovacuum and table bloat.

Statistics per backend, and asynchronous I/O

Two additions have no predecessor to migrate from.

The first is per-backend I/O accounting, exposed by pg_stat_get_backend_io() and cleared by pg_stat_reset_backend_stats(). Until now, attributing physical reads to an individual session meant sampling and inference; this reports it.

The second is the asynchronous I/O subsystem, selected by io_method and tuned with io_combine_limit and io_max_combine_limit, with a view called pg_aios showing the I/O handles currently in flight. Its columns describe the individual operation: the pid issuing it, the state it has reached, the operation, the off and length of the transfer, and a result. Most installations will run on the default method without ever looking at this, which is fine; it becomes interesting when read-heavy sequential work is slower than the storage should allow.

Query identifiers are not continuous across the upgrade

Three changes to how query identifiers are computed land in 18, and together they mean the identifiers on the new cluster are not the ones on the old one.

Constant lists are now jumbled using only the first and last constant, so IN (1, 2, 3) and a longer list of the same shape collapse to one identifier instead of fragmenting into many. Queries against the same relation name in different schemas are grouped together even where the column definitions differ. And CREATE TABLE AS and DECLARE are assigned identifiers and tracked, where previously they were not.

Each of those is an improvement in isolation. The consequence for anyone keeping performance baselines keyed on queryid is the same either way: the old identities disappear and new ones arrive with no history behind them. Snapshot what you have before upgrading, and treat the boundary as a gap rather than reading it as a regression. The mechanics of comparing plans across that boundary are in query plan regression.

pg_stat_statements itself gained parallel_workers_to_launch, parallel_workers_launched and wal_buffers_full. The first two are the more useful pair, because the gap between them is the direct measure of how often the server wanted parallel workers and could not get them.

Smaller changes that still break things

EXPLAIN ANALYZE now includes buffer numbers without being asked. Nothing breaks, but any tooling that parses plan output and assumes BUFFERS is absent unless requested will see extra lines.

log_connections changed from a boolean to a list of options, taking receipt, authentication, authorization, setup_durations or all. The old boolean spellings still work for compatibility, and the positive values map to the first three options. setup_durations is new and genuinely useful: it logs how long connection establishment took, split into forking the backend and authenticating the user, which is the measurement people have been inferring from the application side for years.

Replication slots gained a deliberate expiry. idle_replication_slot_timeout invalidates a slot nobody has read from for long enough, and idle_timeout joins the set of values invalidation_reason can report. That turns an abandoned slot from a disk-space emergency into a broken consumer, which is the trade discussed in replication lag and slot health.

And two vacuum settings arrived that change behaviour rather than reporting. autovacuum_vacuum_max_threshold puts a ceiling on the computed trigger point so a large table cannot keep pushing its own vacuum further away, and vacuum_max_eager_freeze_failure_rate, defaulting to 0.03, lets vacuum opportunistically freeze pages it is already visiting until it has wasted that fraction of the relation trying.

Before you upgrade

The useful exercise is to grep the monitoring configuration rather than to read release notes. Search it for pg_stat_wal and check whether anything reads a timing column from it. Search for op_bytes. Search for anything storing queryid across time. Then run the replacement queries above against a test cluster on 18 and confirm they return rows, because a query that returns nothing on a test box will also return nothing in production, and there it will be indistinguishable from good news.

Each of the changes above now has a page of its own under PostgreSQL 18, with the old query and the new one run against a real server of each version, including the three that break without saying so: a read is no longer a block, the reset call that clears half the log counters and the setting that stopped being a boolean.

We wrote about finding the first of these the hard way in PostgreSQL 18 moved the WAL I/O counters.

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.