11 min read

effective_io_concurrency: The Prefetch Knob PostgreSQL Leaves at 1

The NVMe array could stream 3 GB/s and the range query crept along at 60 MB/s. One GUC explained the gap: effective_io_concurrency was 1, so the kernel heard about pages one at a time.

The server had four NVMe drives good for a combined 3 GB/s, and the query — a selective range scan over forty million rows — was pulling 60 MB/s. EXPLAIN said Bitmap Heap Scan, the right plan. The buffer counts said hundreds of thousands of shared reads. And yet the whole affair ran at a fiftieth of the storage's capability, because effective_io_concurrency was sitting at its pre-18 default of 1, which told PostgreSQL to mention data pages to the kernel one at a time and then wait. The fix was one setting and a reload. The query went from 210 seconds to just under 20.

Bitmap heap scans are the plan shape for "more than a handful of rows, fewer than half the table." The index produces a map of tuple identifiers; the bitmap sorts them into physical order; the heap phase then walks the table in page order, so each page is fetched once instead of once per row. That ordered walk is what makes prefetching possible: PostgreSQL knows which pages it will need well before it needs them, and it can tell the operating system to start reading. The knob that controls how far ahead it asks is effective_io_concurrency.

What does effective_io_concurrency actually control?

It caps how many concurrent prefetch requests PostgreSQL keeps in flight — issued via posix_fadvise with the WILLNEED hint on Linux — and before PostgreSQL 17 the Bitmap Heap Scan was essentially its only consumer. While the heap phase processes the current page, the executor runs ahead through the bitmap, advising the kernel to fault in upcoming pages; when the scan reaches them, the data is already in page cache and the "read" is a memory copy instead of a storage round trip. Sequential scans do not use the knob — they rely on the kernel's own readahead — and plain index scans stay synchronous because their page order is effectively random and data-dependent.

The default history matters because it explains the fleet you actually run. The default was 1 for decades, which disabled meaningful prefetch unless you set it yourself — the folk recommendation was one per spindle on RAID. PostgreSQL 18 finally raised the default to 16 as part of its wider I/O rework, and that is the only version-news sentence you will get here, because the knob still sets prefetch depth on every release you have in production today. A value of 0 disables prefetching outright, which is a valid diagnostic and a terrible production setting on anything with real storage.

Why does the bitmap go lossy, and what does that cost?

The TID bitmap is built in work_mem, and when it outgrows that budget PostgreSQL stops recording individual tuples for the overflow and records whole pages instead — the lossy representation. The heap phase then has to fetch each lossy page and recheck every tuple on it against the index conditions, discarding the ones the bitmap can no longer vouch for. The plan keeps its shape but the work multiplies: I have profiled queries where 80 percent of heap blocks were lossy and "Rows Removed by Index Recheck" was forty times the final row count. You are doing a semi-sequential scan while believing you have a bitmap.

The good news is that EXPLAIN (ANALYZE, BUFFERS) confesses everything. Look for the "Heap Blocks: exact=... lossy=..." line under the Bitmap Heap Scan node, and the recheck line just above it. The fix is work_mem for that query or role, not a bigger hammer: the bitmap for a range covering a few hundred thousand tuples usually fits comfortably in tens of megabytes. Raise work_mem until lossy is zero or near it — the memory is per operation, so size it for the queries that need it rather than globally, and remember every concurrent bitmap scan can hold its own bitmap. Prefetch depth does nothing for a lossy-heavy scan, because the bottleneck has already moved to rechecking; fix the bitmap first, then tune the I/O.

What value should effective_io_concurrency be?

Set it near the number of concurrent reads your storage can actually service, then prove it with a benchmark. For a single modern NVMe device, 64 to 128 is a sane starting range — the drives have deep queues and idle flash channels, and 16 barely wakes them up. For RAID arrays, the old one-per-spindle guidance works as a floor; for cloud volumes, check the published IOPS and queue depth and start at 64. Going past a few hundred almost never pays: the bitmap's lookahead window is finite, the kernel's advisory queue is finite, and past saturation you are tuning a number on a dashboard instead of a query on a machine.

Its sibling, maintenance_io_concurrency, governs the same style of prefetch for maintenance work — VACUUM is the documented consumer, and recovery prefetching uses it too — and defaults to 10. On fast storage, raising it to match effective_io_concurrency shortens vacuum runs on large tables in exactly the same way, and crash recovery that has to read scattered pages benefits too. Both knobs are reloadable: ALTER SYSTEM plus SELECT pg_reload_conf(), no restart. Both act per backend, so a busy system multiplies the effective queue depth by the number of active scans — another reason "as high as possible" is not the answer.

How do you benchmark the change honestly?

Pick the heaviest bitmap plan you run in anger — for us it was a 30-day range over the events table — and measure it three ways: execution time, shared read blocks, and I/O timing, under the same cache conditions across runs. track_io_timing must be on or you are flying without the instrument that matters. My protocol: drop caches once to cold-start, run EXPLAIN (ANALYZE, BUFFERS) three times per setting, keep the median. At effective_io_concurrency = 1 the range query read its pages at 60 MB/s with per-block read times around 0.3 ms — classic serialized small reads. At 64, the same plan streamed at roughly 700 MB/s and total runtime fell from 210 to 19 seconds; at 256 there was no further gain, which told us the storage, not the knob, was now the ceiling. That plateau is the answer to "why not 1000" — you are looking for the knee, not the moon.

ALTER SYSTEM SET effective_io_concurrency = 64;
ALTER SYSTEM SET maintenance_io_concurrency = 64;
SELECT pg_reload_conf();

SET track_io_timing = on;
EXPLAIN (ANALYZE, BUFFERS)
SELECT count(*) FROM events
WHERE created_at >= now() - interval '30 days'
  AND tenant_id = 42;
-- read the Bitmap Heap Scan node: Heap Blocks exact vs lossy,
-- shared read blocks, and I/O Timings per node

One caution on methodology: run the benchmark in something resembling production cache state. A fully cold cache flatters prefetch; a fully warm one hides I/O entirely. The point of the number is the page-fetch path, so make sure the path exists in your test. The broader discipline — reading timing instead of trusting hit ratios — is covered in the notes on debugging high I/O.

Watching prefetch and I/O depth with MonPG

MonPG monitors PostgreSQL in production today, and the fingerprints of this knob are things it already graphs: read blocks per second by backend type, read latency from track_io_timing, and the exact-versus-lossy split you capture in your own EXPLAIN sampling. When a range query gets slower after a deployment, the read-latency and blocks-per-second pair tells you whether the plan changed or the storage did — the two causes need opposite fixes. The PostgreSQL monitoring surface covers how those counters are collected. Check the bitmap for lossy pages, set the prefetch depth your storage has earned, and let the NVMe drives do what you paid for.