Row of ducks wearing yellow hard hats and green safety vests stand on a construction site with cranes and building materials in the background.

maintenance_io_concurrency is effective_io_concurrency for work the documentation describes as done “on behalf of many client sessions.” That phrase is the entire design rationale: a VACUUM is paying down I/O debt that every session ran up, so it can reasonably be more aggressive about read-ahead than any one of them would be. Mechanically the two parameters are the same knob, including the three-era history of what the number means, and I won’t repeat that here. What this post is about is which work is governed by it, because the list is not what the name suggests.

The default is 16 in PostgreSQL 18. It was 10 from 13 through 17, or 0 on platforms without posix_fadvise(), where any other value was an error until 18’s asynchronous I/O stopped caring about the platform. The context is user, the range is 0 to 1000, and it can be overridden per tablespace with ALTER TABLESPACE ... SET (maintenance_io_concurrency = ...), which is the right place for it if your heap and your indexes live on volumes with different queue depths. On 18 there is a further per-process ceiling in io_max_concurrency, and it applies here exactly as it does to the sibling.

What “maintenance” turns out to mean

The consumers, by the version that added them:

  • 13: prefetching heap pages during index tuple deletion, when an index page fills up and the inserting backend has to find out which of the entries marked dead can be removed. (14 rebuilt this as bottom-up deletion; the prefetch survived.)
  • 15: recovery_prefetch. This parameter is the in-flight limit for the startup process prefetching blocks ahead of WAL replay, and the lookahead window is four times it.
  • 17: ANALYZE, whose block sampling was the first read stream to carry the maintenance flag.
  • 18: VACUUM, both heap passes, plus B-tree, GiST, and SP-GiST index vacuuming, pg_prewarm, and autoprewarm.

Two things on that list should bother you. The first entry is not maintenance in any sense a DBA would recognize: it is prefetching done by an ordinary backend in the middle of an ordinary INSERT, while holding a lock on an index page. That is precisely the situation where read-ahead pays off, and it is the parameter’s oldest consumer, but it means “maintenance” was aspirational from the first commit. And the other example the source header comment offers, CREATE INDEX, is not on the list at all. An index build reads the heap through a plain sequential scan, and a sequential scan is governed by effective_io_concurrency. If you raised this parameter hoping to speed up a reindex, you raised the wrong one.

There is also a split inside the list that matters on 18. The read-stream consumers (VACUUM, ANALYZE, the index vacuums, the prewarmers) get real asynchronous I/O under io_method = worker or io_uring, and this parameter is their queue depth. The two older consumers, recovery prefetch and index-deletion prefetch, still issue posix_fadvise() hints in 18. Same number, two mechanisms, one of which only warms the kernel’s page cache.

The clamp nobody mentions

For VACUUM specifically, raising this parameter does less than you would expect, and the reason is the vacuum ring buffer. A read stream may never pin more than half the buffers in its access strategy’s ring (GetAccessStrategyPinLimit() in freelist.c), and VACUUM’s ring is vacuum_buffer_usage_limit, 2MB by default, which is 256 buffers. Half of that is 128, and the stream’s lookahead distance is capped at its pin budget. With io_combine_limit at its default of 16 blocks, the heap passes can never have more than eight full-size reads in flight, whatever maintenance_io_concurrency says. Setting it to 64 changes nothing. Setting vacuum_buffer_usage_limit to 64MB, or passing BUFFER_USAGE_LIMIT to the VACUUM command, does. ANALYZE escapes the clamp on any table large enough to matter, because its sampled blocks are scattered and don’t combine, so sixteen single-block reads are sixteen pins. pg_prewarm escapes it because it uses no ring at all.

You can watch the depth from a second session while a vacuum runs against a cold table:

1SELECT pid, count(*) AS in_flight
2FROM pg_aios
3GROUP BY pid;

On a standby, the number to watch is pg_stat_recovery_prefetch.io_depth, which reports how many prefetches the startup process has outstanding. If replay is lagging and io_depth sits at your maintenance_io_concurrency while the storage has headroom, raise it. The startup process is the only consumer on a standby, so there is nothing to weigh against.

The one setting to avoid is 0. It reads as “no read-ahead for maintenance,” and it does that, but it also silently switches off recovery prefetch on every standby that inherits the setting, because the prefetcher checks this parameter and not only recovery_prefetch. Tuning guides written for spinning disks used to suggest it. Their standbys replay slower than they need to, and nothing in the log says why.

Leave it at 16 on 18. If VACUUM is I/O-bound on storage that rewards queue depth, raise this and vacuum_buffer_usage_limit together, then check pg_aios to confirm the depth moved. On 15 through 17 the only consumer worth tuning for is the standby, and the number that tells you whether to bother is io_depth.