Postgres 18: What Async I/O and UUIDv7 Actually Changed For Me
Async I/O and a built-in uuidv7() were the headline features. One of them quietly fixed a problem I had been working around for two years; the other did less than the benchmarks promised.

Postgres 18 shipped with two features that got most of the attention: an asynchronous I/O subsystem, and a native uuidv7() function. The benchmark posts wrote themselves — the multipliers being quoted for sequential scans make a good headline.
I have now run it long enough to have an opinion that is less exciting and, I think, more useful. One of these features mattered enormously to me and it is not the one with the impressive multiplier.
What async I/O actually does
The old model was straightforward and, in hindsight, obviously limiting. When a backend process needed a page that was not in shared buffers, it issued a read and waited. One request, one wait, then the next. On a spinning disk that made sense — the disk could only do one thing anyway. On modern NVMe storage, which is built to service dozens of concurrent requests, it means the database politely asks for one thing at a time from a device begging to be asked for twenty.
Postgres 18 adds an AIO subsystem that can have multiple reads in flight. The io_method setting controls how: worker (dedicated I/O worker processes, the portable default) or io_uring on modern Linux, which submits directly to the kernel's async interface.
The operations that benefit are the ones that read a lot of pages in a predictable order — sequential scans, bitmap heap scans, and vacuum.
That list is the important part, and it is why my own gains were modest. My workload is almost entirely indexed point lookups: fetch this payment by id, fetch these eSIM packages for this country. Those touch few pages and were never I/O-bound in the way async helps. The place I did see a real change was maintenance — vacuum on the large tables got noticeably faster, which matters more than it sounds like, because vacuum falling behind is how you eventually get a bloat problem at the worst possible moment.
So my honest summary: async I/O is a large win for analytical and maintenance work, a small one for OLTP point-lookup traffic, and worth enabling either way because the downside is near zero.
-- inspect what your build is doing
SHOW io_method; -- 'worker' or 'io_uring'
SHOW io_combine_limit; -- how many blocks get merged into one requestUUIDv7 is the one that mattered
This is the feature I would have upgraded for alone, and its benefit is not throughput — it is the removal of a slow-motion structural problem.
Random UUIDs (v4) are a beautiful idea for distributed identity and a hostile one for a B-tree. Because each new id is random, every insert lands in an arbitrary leaf page of the index. With a table large enough that the index does not fit in memory, this means each insert dirties a different page, buffer locality collapses, and write amplification climbs. Nothing breaks. It just gets slower in a way that correlates with table size, which makes it hard to notice until the table is big.
UUIDv7 puts a millisecond timestamp in the high bits. Ids generated near each other in time sort near each other, so inserts concentrate in the rightmost pages of the index — the same access pattern a bigserial gives you, while keeping the properties that made you choose a UUID in the first place: generatable by the client, no coordination, no leaking of row counts.
CREATE TABLE payment_events (
id uuid PRIMARY KEY DEFAULT uuidv7(),
payment_id uuid NOT NULL,
provider text NOT NULL,
raw_payload jsonb NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);Before 18, getting this meant a pg_uuidv7 extension — which is fine until you deploy somewhere that will not install extensions, and then it is a migration. Having it in core removes an entire category of environment negotiation.
One genuinely useful side effect: because the timestamp is embedded, uuid_extract_timestamp(id) gives you creation time from the key itself. I would not build business logic on that — a column is clearer and survives an id-scheme change — but for debugging an event log it is excellent.
The tradeoff nobody puts in the headline
UUIDv7 leaks time. Anyone holding an id can read roughly when the row was created, to the millisecond.
For a payment event log, that is fine — arguably useful. For anything where the id is exposed to users and creation time is sensitive, think about it before you switch. If two ids let a user infer that account A and account B were created four seconds apart, you have disclosed something. Usually harmless, occasionally not, and it is the sort of thing that is easier to reason about now than to unwind later.
Upgrading
The thing I appreciated most is not on the feature list. pg_upgrade can now carry over planner statistics.
Historically, a major upgrade left you with an empty pg_statistic. The database came up, the application connected, and the planner — reasoning with no statistics at all — produced plans that ranged from mediocre to catastrophic until ANALYZE finished. The upgrade window was short; the recovery window was however long a full analyze took on your largest tables, and that window was when everything looked broken.
Statistics surviving the upgrade removes that cliff. Post-upgrade behaviour now resembles pre-upgrade behaviour, which is the entire thing you want from an upgrade.
One exception is worth knowing before you rely on this: extended statistics are not carried over. CREATE STATISTICS objects survive as definitions, but their computed data does not, so if you have added extended statistics for correlated columns — and if you have ever fought a bad row estimate on a multi-column predicate, you probably have — those come back empty and the planner is guessing again on exactly the queries you built them for.
So still analyze afterwards. vacuumdb --all --analyze-in-stages --missing-stats-only is the precise version: it fills in what did not survive without redoing what did. But the difference between "run analyze soon" and "run analyze before traffic arrives or explain to people why the site is slow" is the difference between a routine maintenance window and an incident.
What I would tell someone deciding
If you are on 15 or 16 and running a normal transactional workload, do not upgrade for the async I/O benchmark numbers — they are real, and they mostly describe a workload that is not yours.
Upgrade for uuidv7() if you use UUID primary keys, because it removes a source of gradual degradation without an extension. Upgrade for statistics-preserving pg_upgrade if you have ever had a bad post-upgrade hour. And enable async I/O when you get there, because vacuum getting faster is a gift you will not notice until the day it saves you.
Sources: PostgreSQL 18.0 release notes; PostgreSQL press kit for 18.
Filed under


