We now enter the io_* neighborhood, the newest one in the GUC namespace: nothing here existed before PostgreSQL 17. If effective_io_concurrency answers “how many I/Os do we keep in flight?”, io_combine_limit answers the orthogonal question: “how big is each one?” Depth and width.
io_combine_limit arrived in PostgreSQL 17. The default is 128kB (16 blocks; unitless values are in BLCKSZ blocks), and the context is user, so any session can change it. In 17 the maximum is 32 blocks (256kB); PostgreSQL 18 raised that to 128 blocks (1MB) on Unix, while Windows stays at 16 blocks because it lacks real scatter/gather I/O. io_max_combine_limit is new in 18: same default, but context postmaster, so it requires a restart. We’ll get to why it exists in a moment.
The mechanics first. Before 17, PostgreSQL read relation data one 8kB block per system call, and a sequential scan of a 184MB table meant roughly 23,600 pread() calls, each with its full ration of syscall overhead, each an individual request for the filesystem to think about. PostgreSQL 17 introduced read streams: code paths that know which blocks they’ll want next (sequential scans and ANALYZE, in 17) announce them in advance, and the stream machinery merges adjacent blocks into a single preadv() of up to io_combine_limit bytes. PostgreSQL 18’s asynchronous I/O builds on the same machinery and extends it to bitmap heap scans, VACUUM, and more; the combined read becomes an asynchronous request, executed by whatever io_method is configured. A few bulk-write paths combine writes under the same limit.
On 18 you can watch it work, because pg_stat_io grew byte counters precisely because I/O size stopped being a constant. A cold sequential scan of that 184MB table:
1 SELECT count(*) FROM big;
2
3 SELECT reads, pg_size_pretty(read_bytes) AS read_bytes,
4 pg_size_pretty((read_bytes / reads)::bigint) AS avg_read_size
5 FROM pg_stat_io
6 WHERE backend_type = 'client backend'
7 AND object = 'relation' AND context = 'bulkread';
8
9 reads | read_bytes | avg_read_size
10 -------+------------+---------------
11 1476 | 184 MB | 128 kB
1,476 system calls instead of 23,600, each one the full io_combine_limit. (Parallel workers report their own pg_stat_io rows, so either disable parallelism for the experiment or sum across backend_type.)
Now, why the second parameter. PostgreSQL 18’s AIO subsystem allocates, at server start, a shared-memory iovec array for every possible in-flight I/O. Sizing all of them for the theoretical 1MB maximum would waste memory nobody asked for, so io_max_combine_limit sets the real allocation once, at startup, and io_combine_limit moves freely underneath it. The effective value is the smaller of the two.
The operational catch is that the clamp is completely silent, and I do mean completely:
1 SET io_combine_limit = '1MB'; -- succeeds
2 SHOW io_combine_limit; -- says 1MB
3 SHOW io_max_combine_limit; -- says 128kB, which is what you actually get
SHOW reports what you asked for, not what you’re getting. On 18, raising the I/O size means raising both parameters, and one of them costs a restart. Plan the restart before the benchmark, not after it produces baffling flat results.
Should you raise it? For once in this series, the default was calibrated for hardware that actually exists, and 128kB is about where most storage stops rewarding larger requests; the kernel and the device will split oversized I/Os at their own segment limits regardless of what you ask for. The case for going higher is analytics-style workloads on storage with high per-request overhead, network-attached storage in particular (EBS and friends), where fewer, larger requests can buy real throughput. Test with io_max_combine_limit = '1MB', vary io_combine_limit per session, and judge by pg_stat_io.read_bytes / reads and read_time, not vibes. If the average read size isn’t moving, you’re clamped somewhere: the parameter, the cap, or the hardware. Everyone else should enjoy the rare pleasure of a modern default and leave both knobs alone.