# PostgreSQL 18: Async I/O, uuidv7 and Skip Scan Explained

> PostgreSQL 18 landed with a real asynchronous I/O engine, native uuidv7(), virtual generated columns by default, B-tree skip scan, and OAuth login. Here is what actually matters for backend work.

URL: https://www.matthewswong.com/en/blog/postgresql-18-async-io-performance/

- Author: Matthews Wong (https://www.matthewswong.com/en/about/)
- Published: 2026-07-31
- Updated: 2026-07-31
- Category: Backend

> **Key takeaway:** PostgreSQL 18, released on 25 September 2025, adds an asynchronous I/O subsystem that reads ahead concurrently for up to 3x faster scans and vacuums, a built-in uuidv7() for time-ordered keys, virtual generated columns as the new default, B-tree skip scan, and OAuth authentication in pg_hba.conf.

I have spent years tuning PostgreSQL for read-heavy ERP workloads, and the one wall I kept hitting was the same: on a big sequential scan or a vacuum, the backend would issue one disk read, block until it returned, then issue the next. On fast NVMe or cloud block storage that leaves most of the device idle, because a single process waiting on a single request cannot keep the queue full. PostgreSQL 18, which reached general availability on 25 September 2025, is the first release that changes that at the storage layer rather than around it.

This is not a cosmetic release. The headline is a genuine asynchronous I/O subsystem, but PostgreSQL 18 also ships several features I have wanted for years as an application developer: a native uuidv7() so I can stop reaching for extensions, generated columns that no longer waste disk, smarter multicolumn index use, and OAuth as a first-class authentication method. Let me walk through each one the way I evaluate it before putting it into production.

## What actually changed in PostgreSQL 18

PostgreSQL major versions arrive yearly, but most bring incremental planner and syntax gains. PostgreSQL 18 is different because its flagship change is architectural: how the server talks to disk. For most of Postgres history, disk reads were synchronous — the executor asked the operating system for a block and waited. Version 18 introduces an asynchronous I/O layer that lets a backend queue several read requests at once, so the storage device stays busy instead of idle between requests. Everything else in this post rides alongside that, but the I/O change is the one that reshapes performance expectations.

## Asynchronous I/O — the headline feature

The new AIO subsystem lets PostgreSQL issue multiple I/O requests concurrently instead of waiting for each one to finish in sequence. In practice that means sequential scans, bitmap heap scans, and vacuum can read ahead: while one block is in flight, the next requests are already queued. The official benchmarking reports performance gains of up to 3x in certain scenarios — and crucially, it also enables effective_io_concurrency and maintenance_io_concurrency above zero on systems that previously lacked fadvise support.

You control it with the io_method server variable. The default is worker, where dedicated worker processes execute the async I/O. On Linux you can switch to io_uring for kernel-native asynchronous I/O, provided the server was built with liburing support. There is also sync, which runs eligible I/O synchronously and effectively opts out. Two companion settings, io_combine_limit and io_max_combine_limit, control how adjacent block reads get merged into a single larger request, and the new pg_aios system view lets you watch in-flight operations.

> Do not switch straight to io_uring in production because it sounds faster. It needs a build compiled with liburing and is Linux-only, and the default worker method already delivers most of the benefit on typical cloud storage. Benchmark your own workload with pg_aios and real query timings before changing io_method — the right value depends on your kernel, storage, and concurrency, not on a blog headline.

## Native uuidv7() for time-ordered keys

Random UUIDv4 primary keys are convenient but hostile to a B-tree: each insert lands at a random point in the index, scattering writes and wrecking cache locality on large tables. UUIDv7 fixes this by prefixing the value with a timestamp, so freshly generated keys sort in roughly insertion order — you get the uniqueness of a UUID with the index-friendliness of a sequence. Before version 18 you needed an extension or client-side code to produce one. PostgreSQL 18 makes uuidv7() a built-in function, and adds a uuidv4() alias so you can be explicit about which version you want.

The value uuidv7() returns is temporally sortable, which is exactly what you want for a default primary key on high-insert tables: index pages fill left to right, page splits drop, and range scans over recently created rows stay tight. For distributed systems it also keeps the merge-friendliness of UUIDs without the write amplification that plagued UUIDv4 as a clustering key.

## Virtual generated columns are now the default

PostgreSQL has had STORED generated columns since version 12 — computed on write and persisted to disk. Version 18 adds virtual generated columns that compute their value on read instead, storing nothing, and makes VIRTUAL the default when you write GENERATED ALWAYS AS without a keyword. For a derived value you rarely filter or index on, that is free: no extra bytes on disk, no write-time cost, and the expression is evaluated only when the column is actually selected.

> Because the default flipped to VIRTUAL, a generated column you assumed was materialized may now be recomputed on every read — and you cannot build a normal index directly on a virtual column. If a generated column is heavily filtered, joined, or indexed, declare it STORED explicitly. Review any migration scripts that omit the keyword and relied on the old stored behavior before upgrading a production schema.

## B-tree skip scan

A classic multicolumn B-tree index on columns like (tenant_id, created_at) was useless to a query that filtered only on created_at, because Postgres could not seek without a value for the leading column. PostgreSQL 18 adds skip scan, which lets the planner use a multicolumn B-tree index even when there is no equality restriction on the first or an early column, as long as there is a useful restriction on a later one. It effectively iterates the distinct leading-column values and skips through the index for each — turning indexes that were previously ignored into usable ones and cutting down on full-table scans.

## OAuth authentication support

PostgreSQL 18 adds an oauth authentication method to pg_hba.conf, letting clients authenticate with OAuth 2.0 flows validated through extension libraries. It brings a server variable oauth_validator_libraries to load token validation modules, new libpq OAuth options on the client, and a --with-libcurl build flag for the required libraries. For teams already standardized on an identity provider, this means database access can finally hang off the same token infrastructure as the rest of the stack instead of a separate password realm.

## PostgreSQL 17 versus PostgreSQL 18 at a glance

| Area | PostgreSQL 17 | PostgreSQL 18 |
| --- | --- | --- |
| Disk reads | Synchronous, one block at a time | Asynchronous via io_method, up to 3x faster scans |
| Time-ordered UUIDs | Needs an extension or client code | Built-in uuidv7() function |
| Generated columns | STORED only, always on disk | VIRTUAL by default, STORED optional |
| Multicolumn index, no leading equality | Index skipped, often a full scan | Skip scan uses the index in more cases |
| OAuth login | Not supported natively | oauth method in pg_hba.conf |

My upgrade plan is deliberately boring: test on a replica first, leave io_method on the worker default and measure real query timings before touching it, and audit every generated column so nothing silently switches from stored to virtual. Then I reach for uuidv7() on new high-insert tables and let skip scan quietly rescue queries the planner used to ignore. PostgreSQL 18 is the rare release where the biggest win — asynchronous I/O — costs nothing to adopt beyond the upgrade itself.

## Sources

- PostgreSQL 18 Released — official announcement (postgresql.org)
- PostgreSQL 18 Release Notes (postgresql.org docs)
- Resource Consumption — io_method and AIO settings (postgresql.org docs)
- UUID Functions — uuidv7() and uuidv4() (postgresql.org docs)
- OAuth Authentication (postgresql.org docs)

## Source links

- https://www.postgresql.org/about/news/postgresql-18-released-3142/
- https://www.postgresql.org/docs/18/release-18.html
- https://www.postgresql.org/docs/18/runtime-config-resource.html
- https://www.postgresql.org/docs/18/functions-uuid.html
- https://www.postgresql.org/docs/18/auth-oauth.html

## FAQ

### When was PostgreSQL 18 released?

PostgreSQL 18 reached general availability on 25 September 2025. It is a major release headlined by a new asynchronous I/O (AIO) subsystem, alongside native uuidv7(), virtual generated columns as the default, B-tree skip scan, and OAuth authentication.

### What does asynchronous I/O in PostgreSQL 18 actually improve?

The AIO subsystem lets a backend queue multiple read requests at once instead of waiting for each in sequence, so storage stays busy. It speeds up sequential scans, bitmap heap scans, and vacuum, with official benchmarks reporting gains of up to 3x in certain scenarios.

### What is the difference between io_method worker, io_uring, and sync?

worker is the default and runs async I/O in dedicated worker processes. io_uring uses kernel-native async I/O on Linux and needs a build with liburing. sync runs eligible I/O synchronously, effectively opting out. Most workloads should stay on worker unless benchmarks prove otherwise.

### Should I use uuidv7() instead of uuidv4() for primary keys?

For high-insert tables, yes. uuidv7() prefixes a timestamp so keys are time-ordered, which keeps B-tree index pages filling left to right and reduces page splits versus random UUIDv4. PostgreSQL 18 makes uuidv7() a built-in function, so no extension is needed.

### Are generated columns now virtual by default in PostgreSQL 18?

Yes. Writing GENERATED ALWAYS AS without a keyword now creates a VIRTUAL column computed on read, storing nothing. If you need to index, join, or heavily filter the column, declare STORED explicitly, and review migrations that relied on the old stored default before upgrading.

---

- HTML version: https://www.matthewswong.com/en/blog/postgresql-18-async-io-performance/
- Bahasa Indonesia: https://www.matthewswong.com/id/blog/postgresql-18-async-io-performance/
- Site index for agents: https://www.matthewswong.com/llms.txt
- Sitemap: https://www.matthewswong.com/sitemap.xml
- API description (OpenAPI): https://www.matthewswong.com/openapi.json
