Most parameters that change across versions change their default. effective_io_concurrency is rarer: it has changed its meaning twice, and what it governs in PostgreSQL 18 is not what it governed in 17, which in turn was not what it meant before 13. A value you copied from a 2019 tuning guide into a modern cluster is not just stale — it may be answering a question PostgreSQL no longer asks. So this one is best told as a history. The current state, to anchor us: default 16, context user, range 0 to 1000, where 0 disables the feature.
Era one: the spindle oracle (pre-13)
The parameter arrived in 8.4 to drive prefetching for bitmap heap scans via posix_fadvise() — telling the kernel “I’m about to want these pages, start fetching them” so that I/O could overlap instead of happening one blocking read at a time. So far so reasonable. The strange part was how the number you set became the number of pages actually prefetched. Through PostgreSQL 12, effective_io_concurrency was not used directly; it was fed through a formula —
1 prefetch distance = sum over i=1..N of 1/i
— the Nth partial sum of the harmonic series, where N was your setting. Set it to 1 and you prefetched 1 page; set it to 10 and you got roughly 3; set it to 100 and you got about 5. The rationale, such as it was, came from the parameter’s mental model: the value was supposed to represent the number of spindles in your RAID array, the count of physical disks that could seek independently, and the formula was a guess at how deep to prefetch given that many independent actuators. Nobody could intuit a good value from it, the mapping from setting to behavior was opaque, and as Tomas Vondra and Robert Haas observed when they later moved to fix it, the whole premise had rotted: flash storage has no spindles, and even rotating disks increasingly hide behind a SAN’s virtualization where you couldn’t count them if you tried.
Era two: the number becomes itself (13–17)
PostgreSQL 13, on Thomas Munro’s work, threw the formula out. From 13 onward effective_io_concurrency means what everyone always assumed it meant: the number of concurrent I/O requests, used directly, no harmonic series in the way. This was a genuine behavior change, not just a cleanup — the same numeric value produced a different prefetch depth before and after 13. The release notes even shipped a conversion incantation for anyone carrying an old setting forward:
1 SELECT round(sum(OLDVALUE / n::float)) FROM generate_series(1, OLDVALUE) s(n);
Run with your pre-13 value, that returns the post-13 value producing equivalent behavior — which means an unconverted setting was silently wrong across the upgrade, usually in the direction of prefetching far less than you thought. The same release also spun off maintenance_io_concurrency — a sibling for prefetching during maintenance work like vacuum, defaulting higher, on the theory that maintenance can afford to be more aggressive than user queries. Throughout this era the mechanism was still fundamentally posix_fadvise: PostgreSQL advised the kernel to pull pages into the OS page cache, and the advice did nothing on platforms lacking the call (Solaris has it but it’s a no-op; some others lack it entirely, and there the parameter could only be 0).
Era three: real asynchronous I/O (18)
PostgreSQL 18 changed the ground beneath the parameter again, this time by giving PostgreSQL a real asynchronous I/O subsystem rather than a polite request to the kernel. The new io_method parameter selects how reads happen — sync (the old blocking-with-posix_fadvise behavior, kept for compatibility), worker (the default, using dedicated background I/O worker processes), or io_uring (Linux’s modern async interface, where available). Against that machinery, effective_io_concurrency stops being a fadvise hint and becomes a direct control on how many asynchronous read-ahead requests PostgreSQL itself issues, now reaching beyond bitmap heap scans to sequential scans and vacuum’s read path. The effective read-ahead depth is roughly effective_io_concurrency × io_combine_limit, the latter being a new parameter governing how many pages fuse into a single I/O.
This is why the default jumped from 1 to 16 in 18 — the source literally redefines DEFAULT_EFFECTIVE_IO_CONCURRENCY from 1 to 16. The old default of 1 was a confession that synchronous fadvise prefetching barely helped; with a real AIO subsystem keeping many reads genuinely in flight while backends do useful work, a depth of 16 is a sane starting point rather than an act of optimism. The old advisory mechanism only ever populated the OS page cache; the new one can fill PostgreSQL’s own shared buffers.
What to actually set
On PostgreSQL 18, the default of 16 is reasonable; raise it for fast NVMe or cloud storage that rewards deeper queues — community testing suggests values up to a couple hundred on capable SSDs — but benchmark, because too high a value can lift I/O latency for every query while you chase throughput on a few, and the gains plateau. On 13 through 17 you’re tuning posix_fadvise prefetch depth for bitmap heap scans; the historical advice of roughly 200 for SSD and a low double-digit number for spinning disks still applies, with the same instruction to measure. On anything before 13, recognize that your setting is a harmonic-series input, not a request count, and that the number in your config does not mean what it looks like — and the better move than tuning it is upgrading off a version where this parameter requires a footnote to use.
The through-line across all three eras is the only durable advice: this parameter’s number has meant three different things, so never copy a value between major versions without checking which era each one lives in. Set it from your actual storage and your actual PostgreSQL version, measure with a real workload, and distrust any tuning guide that doesn’t say which version it was written for.