For most of Postgres's history, a backend asking the kernel for a page of data looked something like this: ask, wait, get the page, ask again. One outstanding read at a time, per process. On a modern NVMe drive that can service hundreds of thousands of IOPS in parallel, that is a database holding a sports car at idle in a school zone.
Postgres 18, released September 25, 2025, finally lets it shift gears. The headline feature is a new asynchronous I/O subsystem that lets a single backend keep many reads in flight at once. The release notes claim up to 3x faster reads in the right conditions. Independent benchmarks from pganalyze land in the same neighborhood: a cold-cache COUNT(*) over 100 million rows drops from 15,830 ms on Postgres 17 to 5,723 ms on Postgres 18 with io_method=io_uring. A 64% reduction in wall time on the same hardware, from a config change.
This is the kind of release that sounds like a benchmark headline and turns out to be an architectural shift. Worth unpacking what actually changed, where the gains evaporate, and what to do about it on a real cluster.
What Postgres was doing before
Until 18, Postgres's I/O model was synchronous. A backend executing a sequential scan would fault on a buffer pool miss and the process would block until the page came back. The only concurrency was at the process level: more connections meant more parallel I/O, but each individual query still walked its data one block at a time.
Postgres 12 added effective_io_concurrency with posix_fadvise(), which let bitmap heap scans hint to the kernel about upcoming reads. It helped, but it was a workaround. The backend was still blocking; the kernel was just doing speculative prefetching on the side. Postgres 17 quietly laid groundwork by introducing the read stream API, which collected adjacent reads into batches the storage layer could optimize. None of that was real asynchrony. It was preparation for it.
The pganalyze writeup has the cleanest framing of the old model:
Every read request is a blocking system call - like an imaginary librarian who retrieves one book at a time, returning before fetching the next.
That librarian is what Postgres 18 fires.
Three doors: sync, worker, io_uring
The new behavior is gated behind a single GUC: io_method. It has three values.
sync- the old behavior. This is still the default in 18, which surprised a lot of people. Upgrade and you get none of the new performance until you flip the switch.worker- Postgres spawns a pool of dedicated I/O worker processes, controlled byio_workers. Backends hand off read requests to the pool and continue. Works on every platform Postgres supports.io_uring- on Linux 5.1+, reads are submitted directly to the kernel via io_uring. No worker processes, no extra context switches. Fastest, but Linux-only and requires the Postgres binary to be built with--with-liburing.
Two more parameters got their defaults bumped: effective_io_concurrency and maintenance_io_concurrency both default to 16 now. That alone is a non-trivial change. The old defaults were calibrated for spinning disks, and most production clusters had already overridden them, but the new floors are reasonable on NVMe.
The operations that benefit from AIO in 18 are narrower than the marketing suggests: sequential scans, bitmap heap scans, and VACUUM. That is the whole list. Which leads directly to the part that gets missed.
Where the speedup isn't
If your workload is dominated by OLTP point lookups via B-tree indexes, AIO will do approximately nothing for you. PlanetScale's 17 vs 18 sysbench run - 300 GB database, oltp_read_only workload across mixed range sizes - found that across EBS-backed configurations, the gap between Postgres 17 and 18 was small, and io_uring sometimes underperformed sync at low concurrency on network storage. Only on local NVMe at high concurrency did the new modes pull ahead, and even then the gains were modest.
The reason is that index scans don't use AIO yet. Andres Freund, who led the AIO project, is direct about this on the Talking Postgres podcast:
Index scans do not yet use AIO. The speedups are 8x, 9x.
The 8-9x is what is not yet on the table. Writes are also still synchronous in 18 - the WAL writer, checkpointer, and background writer do not issue async I/O. Freund spent, by his own account, "at least a year and a half" trying to make asynchronous WAL writes work before walking back to a cleaner design that punts writes to a future release.
So the honest version of the headline is this: Postgres 18 makes scans and vacuum dramatically faster on fast storage. It does not (yet) make most production workloads dramatically faster. The bigger gains are in releases that have not shipped.
The benchmarks that look real
Set that caveat aside and the cold-scan numbers are still striking. pganalyze ran a 3.5 GB sequential scan on a c7i.8xlarge with EBS:
Postgres 17 (sync): 15,830 ms (baseline)
Postgres 18 (sync): 15,071 ms ~5% faster
Postgres 18 (worker): 10,052 ms ~36% faster
Postgres 18 (io_uring): 5,723 ms ~64% fasterTwo things to read out of that table. First, io_method=sync on Postgres 18 is barely faster than Postgres 17. The gains require opting in. Second, io_uring is roughly twice as fast as worker. The worker path is cheap to enable and works everywhere; the io_uring path is where the headline 3x number actually lives, and it needs a Linux kernel new enough and a Postgres build with liburing linked in.
Managed providers handled this unevenly at launch. The build flag matters: a Postgres 18 binary without liburing will refuse io_method=io_uring at startup. Check before assuming.
The observability shift nobody warned you about
AIO changes what Postgres looks like from the outside. EXPLAIN ANALYZE now under-reports I/O time, because the backend process is no longer the one blocking on reads. The work happens in I/O workers or in the kernel, and the backend just collects results.
Wait events shift accordingly. The old familiar IO/DataFileRead - the bread-and-butter signal that a query was reading from disk - is gradually being replaced by IO/AioIoCompletion, which means "the backend is waiting for a previously-issued async read to finish." If you have dashboards filtering on the old wait event name, they will start to look quiet for the wrong reason.
There is a new system view to compensate: pg_aios. It exposes in-flight async I/Os with their state (SUBMITTED, COMPLETED_IO), operation type, offsets, and affected blocks.
SELECT pid, io_method, op, state, fd, off, nbytes
FROM pg_aios
WHERE state <> 'IDLE';If you have been operating Postgres long enough that you reflexively run pg_stat_activity to figure out what a slow query is doing, get pg_aios into that reflex. The two views together are the new shape of "why is this query slow."
Skip scan, uuidv7, and the rest of the release
AIO is the headline, but a few other 18 features quietly matter.
Skip scan, enabled via enable_skip_scan, lets the planner use a multicolumn B-tree index even when the leading column has no equality predicate. The classic case: an index on (tenant_id, created_at) used to be useless for WHERE created_at > now() - '1 day' if the planner could not bound tenant_id. 18's skip scan iterates through the distinct prefix values internally. For low-cardinality leading columns - tenants, account IDs, status enums - this eliminates a whole category of "I have to add a second index" workarounds.
Then there is uuidv7(), finally in core. Time-ordered UUIDs are not new - libraries have shipped them for years - but having it as a built-in function means application code stops needing a UUID library just to get sane index locality:
CREATE TABLE event (
id uuid PRIMARY KEY DEFAULT uuidv7(),
body jsonb NOT NULL
);The reason this matters: random UUIDs (v4) scatter inserts across the B-tree, fragmenting index pages and trashing cache locality. v7's leading timestamp keeps new rows in the same hot page range. On insert-heavy workloads this is a real, measurable win, and unlike AIO it costs nothing to adopt.
Virtual generated columns are now the default for the GENERATED syntax (you have to spell out STORED if you want the old behavior). And RETURNING gained OLD and NEW qualifiers in UPDATE, which is the kind of thing you do not appreciate until you have written the workaround three times.
Turning it on
If you are upgrading to 18, or your managed provider already did, the minimum useful config change is:
# postgresql.conf
io_method = io_uring # or 'worker' if not on Linux
io_workers = 8 # only meaningful for io_method=worker
effective_io_concurrency = 32 # bumped from new default 16
maintenance_io_concurrency = 32Then run a sequential scan over a table that does not fit in shared buffers, check that pg_aios shows non-idle rows during the scan, and compare wall-clock against the previous release. If you do not see a meaningful difference, your workload is probably index-bound, which is the honest answer for most OLTP.
One caveat from the field: there was a reported issue in 18 beta1 where the postmaster used noticeably more CPU with io_method=io_uring. By 18.0 GA the worst of that had been addressed, but if you see anomalous CPU on a cluster newly switched to io_uring, check it is not the postmaster doing accounting overhead before blaming the new mode.
What this is actually setting up
The honest story of Postgres 18's AIO is that it is a foundation release. The user-visible wins are real but bounded - they apply to a specific subset of workloads, and the most exciting gains (Freund's 8-9x on index scans) are explicitly future work.
That is fine. Postgres has done this before. Partitioning in 10 through 12 was a multi-release arc, parallel query similarly. AIO is a multi-release arc that finally has the infrastructure in. The same plumbing that makes sequential scans 3x faster in 18 is what will make index scans 8x faster in 19 or 20, and writes async after that. The hard part - and Freund spent six years on this - was the architectural pattern that lets reads, vacuum, and eventually writes share one I/O subsystem without each path reinventing it.
Andres put the design intent in one line:
The goal of this is to basically give the operating system and the storage the information to do more efficient reading.
Postgres has run on the assumption that the OS knows how to read efficiently for ~30 years. 18 is the version where it admits the OS could do better with more information, and starts giving it.
Upgrade. Flip io_method. Watch pg_aios. Do not expect a free OLTP boost - but do expect your nightly vacuum window to get shorter and your analytics queries to stop pretending it is still 2009.
