Back to blog

PostgreSQL's Async I/O Engine: Why Your Sequential Scans Just Got 2-3x Faster

Aug 8, 2026
4 min read
JJS
Written by Jatin Jain Saraf · Senior Software Engineer

For as long as PostgreSQL has existed, reading a page from disk has looked the same: a backend process calls read(), the OS schedules the I/O, and the process blocks until the page comes back. Then it asks for the next one. One request, one wait, repeat.

That's fine when your data is sitting in memory. It becomes a real problem on modern NVMe storage. An NVMe drive can have 32 or more I/O requests in flight at once, happily serving them in parallel. Postgres, until version 18, never took advantage of that. It asked for one page, waited for it to come back, then asked for the next. The hardware was capable of a highway; Postgres was driving it one car at a time.

PostgreSQL 18 changes that with a real asynchronous I/O subsystem. Here's the mental model worth keeping: synchronous I/O is a single waiter who takes one table's order, walks it to the kitchen, stands there until it's ready, and only then goes to take the next order. Async I/O is that same waiter dropping ten orders at the kitchen window at once and picking up whichever plates are ready as they come up. Same kitchen, same waiter, dramatically less standing around.

What actually changed

Postgres now has a pluggable I/O backend, controlled by a new io_method setting:

ini

io_uring is the interesting one. It's a Linux kernel interface that gives Postgres two ring buffers shared with the kernel: one to submit I/O requests, one to collect completions. Submitting a batch of reads costs a single syscall instead of one syscall per page. On worker, a pool of background threads makes the blocking calls on your process's behalf so your backend never has to sit and wait itself.

The parameter that actually controls the payoff for read-heavy workloads is effective_io_concurrency. Before PG18 this only affected bitmap heap scan prefetching. Now it controls how many pages ahead a sequential scan, a VACUUM, or a checkpoint will request before it actually needs them:

ini

Why this matters in production

The scenarios where this pays off are the ones that were always I/O-bound: a nightly ETL job doing a full sequential scan of a fact table, VACUUM chewing through a heavily-updated table whose pages aren't in shared_buffers, a checkpoint flushing a large batch of dirty pages, a replication standby trying to flush WAL fast enough to keep lag down.

On paper, a sequential scan of a 10GB table doing 1.3 million page reads at roughly 50 microseconds each costs about 65 seconds fully synchronous. With requests batched 32-deep, that drops toward single-digit seconds in theory. Real workloads don't hit the theoretical ceiling (there's coordination overhead, and the OS page cache intervenes), but a 2-3x throughput improvement on genuinely I/O-bound scans is a realistic, repeatable result, not a marketing number.

The part that matters just as much: if your working set already fits in shared_buffers or the OS page cache, async I/O buys you almost nothing. A cache hit has no I/O latency to overlap in the first place. This isn't a "just turn it on and everything gets faster" feature. It's a fix for exactly one bottleneck: waiting on disk.

Verifying it on your own workload

PostgreSQL 18 also ships pg_stat_io, the first built-in view that breaks down I/O by process type and operation. This is how you check whether async I/O is actually doing anything for you, instead of taking it on faith:

sql

Run that once with effective_io_concurrency = 1 and once with it set to something like 64, on the same cold query, and compare read_time. If the number barely moves, your data was already cached and async I/O was never going to help. If it drops meaningfully, that's your workload confirming it was genuinely I/O-bound and the new prefetching is doing real work.

What it doesn't change

It's worth being precise about the boundaries. Async I/O doesn't touch MVCC, doesn't change the durability guarantees around WAL flushing, doesn't change lock acquisition, and doesn't reduce the per-connection process overhead that still makes connection pooling mandatory at scale. It's a storage-layer optimization, full stop. Everything above the buffer manager works exactly as it did before.

PostgreSQL isn't getting a new execution model here. It's getting permission to stop waiting in line.

Discussion

0

Join the discussion

Loading comments...