Skip to main content
RunBook Academy

PostgreSQLXII · WAL, Checkpoints and Crash RecoveryWAL

Background writing and the PostgreSQL 18 I/O subsystem

Advanced⏱ ~30 minpsql

What you'll learn

  • Distinguish the three processes that write dirty buffers and when each does
  • Read maxwritten_clean and act on it
  • Explain what the PostgreSQL 18 asynchronous I/O subsystem changes
  • Use pg_stat_io and pg_aios to attribute I/O correctly

Prerequisites

Verified against PostgreSQL 18.x · PostgreSQL (comparison targets) 17.11, 16.15 · PostgreSQL (support calendar) 18, 17, 16, 15, 14 supported · pgBackRest 2.59.1 · PgBouncer 1.25.2 · Patroni 4.1.5 · Ubuntu (host baseline) 26.04 LTS · 2026-08-27

Not yet marked complete on this device.

Three different processes write dirty buffers out of the pool, for three different reasons, and one of them is a symptom rather than a mechanism.

Who writes what

WriterWhenReported in
CheckpointerOn schedule, spread across the intervalpg_stat_checkpointer.buffers_written
Background writerContinuously, ahead of demandpg_stat_bgwriter.buffers_clean
BackendsWhen a backend needs a buffer and none is cleanpg_stat_io, context normal

The third is the one to care about. A backend writing a dirty buffer is a query waiting on a write it did not ask for, and it is the direct cost of a pool with nothing clean in it.

Read-only / Safepg_stat_bgwriter after two and a half hours of load
$ psql -U postgres -x -c "SELECT * FROM pg_stat_bgwriter"
buffers_clean    | 97984
maxwritten_clean | 570
buffers_alloc    | 2131374
stats_reset      | 2026-08-27 18:31:22.55921+00

maxwritten_clean counts how often the background writer stopped early because it hit bgwriter_lru_maxpages. 570 occurrences means it was repeatedly capped while there was still work to do.

The PostgreSQL 18 asynchronous I/O subsystem

This is the largest change to PostgreSQL’s I/O behaviour in years. From the release notes:

Add an asynchronous I/O subsystem … This feature allows backends to queue multiple read requests, which allows for more efficient sequential scans, bitmap heap scans, vacuums, etc. This is enabled by server variable io_method, with server variables io_combine_limit and io_max_combine_limit added to control it. This also enables effective_io_concurrency and maintenance_io_concurrency values greater than zero for systems without fadvise() support. The new system view pg_aios shows the file handles being used for asynchronous I/O.

Read-only / Safethe settings as shipped in 18.6
$ psql -U postgres -c "SELECT name, setting, boot_val FROM pg_settings WHERE name IN ('io_method','io_workers','io_combine_limit','io_max_combine_limit')"
         name         | setting | boot_val
----------------------+---------+----------
io_combine_limit     | 16      | 16
io_max_combine_limit | 16      | 16
io_method            | worker  | worker
io_workers           | 3       | 3
Read-only / Safewhat io_method accepts on this build
$ SELECT name, setting, enumvals FROM pg_settings WHERE name IN ('io_method','io_combine_limit','io_max_combine_limit');
         name         | setting |        enumvals
----------------------+---------+------------------------
io_combine_limit     | 16      |
io_max_combine_limit | 16      |
io_method            | worker  | {sync,worker,io_uring}

Three values on this build. enumvals is the right way to ask, because the list depends on how the binary was compiled — a build without liburing will not offer io_uring at all.

worker — the default — uses dedicated I/O worker processes, of which there are three by default. sync reverts to the previous synchronous behaviour, which is the comparison to make if you suspect a regression. io_uring uses the Linux io_uring interface and is available only where the build and kernel support it.

io_combine_limit is how many blocks may be merged into one I/O request: 16 blocks is 128 kB. Raising it can help large sequential reads on storage that rewards large requests, and io_max_combine_limit bounds what io_combine_limit may be set to.

pg_stat_io

pg_stat_io attributes I/O by who did it, to what, and in what context. PostgreSQL 18 added byte-level columns. From the release notes:

Add pg_stat_io columns to report I/O activity in bytes … The new columns are read_bytes, write_bytes, and extend_bytes. The op_bytes column, which always equalled BLCKSZ, has been removed.

and

Add WAL I/O activity rows to pg_stat_io … This includes WAL receiver activity and a wait event for such writes.

Read-only / Safepg_stat_io with the 18 byte columns, on a busy replication primary
$ SELECT backend_type, object, context, reads,
     pg_size_pretty(read_bytes::numeric)   AS read,
     writes, pg_size_pretty(write_bytes::numeric) AS written,
     extends, pg_size_pretty(extend_bytes::numeric) AS extended
FROM pg_stat_io
WHERE reads > 0 OR writes > 0 OR extends > 0
ORDER BY reads DESC NULLS LAST LIMIT 8;
    backend_type    |  object  | context  | reads |  read   | writes | written | extends | extended
--------------------+----------+----------+-------+---------+--------+---------+---------+----------
client backend     | relation | normal   | 68667 | 537 MB  |    461 | 3688 kB |    1995 | 16 MB
autovacuum worker  | relation | vacuum   | 11155 | 111 MB  |   9466 | 74 MB   |       0 | 0 bytes
walsender          | wal      | normal   |  1164 | 140 MB  |   7986 | 86 MB   |         |
client backend     | relation | vacuum   |   905 | 113 MB  |      0 | 0 bytes |       0 | 0 bytes
client backend     | relation | bulkread |   827 | 56 MB   |      0 | 0 bytes |         |
background worker  | relation | bulkread |   811 | 54 MB   |      0 | 0 bytes |         |
standalone backend | relation | normal   |   479 | 4336 kB |   1038 | 8304 kB |     598 | 5464 kB
autovacuum worker  | relation | normal   |   296 | 3392 kB |      2 | 16 kB   |      18 | 160 kB

Several things are readable here that no other view offers.

The walsender / wal row is the 18 feature doing its job: WAL I/O now appears in pg_stat_io, attributed to the process performing it. On a primary with a standby attached, that row is replication reading WAL to ship it. On a cluster that has just crash-recovered, the equivalent row is startup / wal — the recovery in lesson XII-06 reading WAL to replay it.

context = bulkread is the ring buffer from lesson XI-02, keeping large scans out of the main pool. Separating it from normal shows how much of your read volume is scans versus working-set access.

extend_bytes of 94 MB on a client backend is relation extension — a table growing. It is not a read or a write in the ordinary sense and it was invisible before this column existed.

Writes attributed to client backend in normal context are the symptom from the top of this lesson: backends writing dirty buffers because nothing was clean.

-- the specific query: are backends doing the checkpointer's work?
SELECT backend_type, context, writes, pg_size_pretty(write_bytes) AS written
  FROM pg_stat_io
 WHERE backend_type = 'client backend' AND writes > 0;

What to take from this

  • Three writers: checkpointer on schedule, background writer continuously, backends when desperate. The third is the symptom.
  • maxwritten_clean rising means bgwriter_lru_maxpages is capping the writer. Measured here at 570.
  • The background writer is among the least impactful things to tune. Tune it on evidence.
  • PostgreSQL 18’s asynchronous I/O subsystem is controlled by io_method, with worker the default and sync the way to test whether it is implicated.
  • effective_io_concurrency now defaults to 16 and works differently. Pre-18 advice about it describes another mechanism.
  • pg_stat_io attributes I/O by backend type, object and context, with byte columns new in 18. pg_aios is a live view for one question.

Cross-course references

  • Linux for Production Sysadmins — Part XLI (Storage Performance) covers the queue depth and completion behaviour that PostgreSQL 18’s I/O methods are trying to exploit.
  • Observability for Production Sysadmins — Part LIX (Database observability) covers the pg_stat_io series that make this subsystem’s behaviour visible, which is where the older pg_stat_bgwriter columns went.

Quiz

Knowledge check · 6 questions

  1. Q1. pg_stat_io shows a client backend with a substantial writes figure in the normal context. What does that indicate?

  2. Q2. A cluster upgraded to PostgreSQL 18 shows a change in read-path performance. Which setting is the appropriate diagnostic first step?

  3. Q3. Querying pg_aios during a period of heavy sequential scanning returns zero rows. What does that mean?

  4. Q4. Which observations are available from pg_stat_io that other views do not provide? Select all that apply.

  5. Q5. Guidance about effective_io_concurrency written for PostgreSQL 15 applies unchanged to PostgreSQL 18.

  6. Q6. How would you establish whether the background writer needs tuning on a given cluster?

Passing score: 75%. Answers are checked in this browser.