Skip to main content

PostgreSQL 18's Async I/O on Owned NVMe: What io_uring Reads, uuidv7, and a CNPG Upgrade Path Mean for Operator-Owned Postgres

9 min readDora NodaDora Noda
Share
On this page

PostgreSQL 18 is the first release in years where the storage engine, not the query planner, is the reason to upgrade. Its new asynchronous I/O subsystem lets backends issue many disk reads at once instead of stalling on each one, and the official benchmarks show cold reads running up to 3x faster. But that headline number comes with a shape most summaries skip: the gain is large on cold sequential and bitmap scans, near zero on warm-cache and write-heavy workloads, and exactly zero if your node image can't do io_uring. On owned hardware, PostgreSQL 18 is a faster database behind a short checklist — and the upgrade itself is still yours to run.

Here is the verdict up front, with the sensitivity the single headline number hides:

Workload shapeio_method=sync (PG17 behavior)workerio_uring
Cold sequential scan, data on NVMeBaseline~1.6x faster~2.8x faster
Cold bitmap heap scanBaselineFasterFastest (biggest win in published tests)
Warm cache (shared_buffers hit)Baseline~0 gain~0 gain
OLTP write-heavy (WAL-bound)Baseline~0 gain~0 gain

The cold-scan figures come from published PostgreSQL 18 benchmark decks: a PGDU conference deck measured sync as baseline, worker at 1.6x, and io_uring at 2.8x on the same read workload, while pganalyze's webinar showed a 100M-row COUNT(*) dropping from 10.1s (worker) to 5.7s (io_uring). The zeros are just as important: AIO accelerates reads issued through the new read-stream interface, so workloads that rarely touch disk, or that wait on WAL writes instead, see nothing. Know which workload you run before you promise anyone a speedup.


What PostgreSQL 18 ships that an operator actually cares about

Beyond AIO, version 18 (September 2025) packs several operator-relevant changes. Each row below is the feature plus the reason it matters to whoever owns the machines:

FeatureWhat it isWhy the operator cares
Async I/O (io_method)sync, worker (portable), or io_uring (Linux) read pathsThe upgrade's headline payoff; see the checklist below before expecting it
uuidv7()Native time-ordered UUID generationBetter B-tree locality for new primary keys; see the rule below
B-tree skip scanMulticolumn indexes serve queries that omit the leading columnFewer redundant indexes to maintain on wide tables
Virtual generated columns by defaultComputed at read time unless declared STOREDGotcha: virtual columns can't be indexed — audit schemas that assumed STORED
Checksums on by defaultNew clusters get data checksums without a flagSilent-corruption detection for free on fresh installs
pg_upgrade keeps planner statsStatistics survive major upgradesNo post-upgrade ANALYZE storm before plans stabilize
OAuth authenticationToken-based auth alongside SCRAM/certsOne less password to rotate for app fleets

One scope note that saves confusion later: PostgreSQL 18's AIO covers reads only — sequential scans and bitmap heap scans issued through read streams. The write path, WAL, and checkpoints are unchanged; asynchronous writes and production direct I/O are future-direction work, not part of this release. If your bottleneck is write throughput, 18's headline feature is not your fix.

The uuidv7 rule: new keys yes, rewrites no

uuidv7() deserves its own paragraph because the title promised it and the decision is genuinely binary. Version 7 UUIDs embed a timestamp in the high bits, so monotonically-inserted keys land in adjacent B-tree pages instead of scattering across the index the way random v4 values do. Less scatter means less page splitting and less index bloat over time.

The operator rule is simple: use v7 for new primary keys, leave existing v4 keys alone. Rewriting a live primary key to change its UUID version buys locality at the cost of a full table rewrite plus every foreign key that references it — a migration whose risk dwarfs the fragmentation it cures. Adopt v7 at the next schema you design, not as a retrofit project.


The owned-NVMe checklist before io_uring pays

On a managed service, someone else answers every question below. On machines you own — a Hetzner box, a Cluster API-provisioned node, a Kubernetes StatefulSet with local NVMe — each one is yours. Walk the list in order; the first "no" is where your speedup dies.

#CheckHow to verify
1Kernel ≥ 5.1 with io_uring enableduname -r; confirm the node image doesn't disable io_uring via sysctl or block it in seccomp
2Postgres built with liburingSHOW io_method accepts io_uring, or check the build's configure summary; a build without liburing silently can't select it
3Container seccomp permits io_uring syscallsContainer runtimes with default seccomp profiles can block io_uring_enter; test the actual pod spec, not the host
4Filesystem and direct-I/O alignment saneNVMe volume formatted and mounted per your distro's guidance; misaligned direct I/O turns fast reads into read-modify-write penalties
5io_workers, io_combine_limit, io_max_concurrency tunedStart from defaults, then measure; combining adjacent reads into fewer, larger I/Os is where much of the win lives
6Verify with real plans, not vibesEXPLAIN ANALYZE now shows BUFFERS by default in 18; cross-check AIO behavior with the new pg_aios view

Two notes on the table. First, check 3 bites Kubernetes operators most often: the host kernel can be perfectly capable while the container's seccomp profile rejects the exact syscalls io_uring needs. The failure mode is a fallback or an error at startup, not a helpful warning mid-query.

Second, check 5's tunables interact with your storage latency. High-latency network-attached volumes benefit from deeper concurrency than local NVMe, where published tests show the gap between worker and io_uring narrowing as latency drops. Measure on your own volume type.


The upgrade path, button vs runbook

Also in September, Railway shipped the thing every self-hosted operator is now implicitly compared against: one-click Postgres major version upgrades. From Database → Config → Major Version Upgrade, you review a compatibility report, pick a target (14 through 17 to any newer major Railway publishes), and Railway takes a backup, upgrades in place, redeploys, takes another backup, and tracks progress in one flow. HA clusters get the primary upgraded and replicas rebuilt. The database is unavailable for typically a few minutes depending on data size, and a limited rollback window restores the pre-upgrade backup — discarding writes made after the upgrade.

The CloudNativePG equivalent is genuinely declarative, but "declarative" and "one click" are not the same job. Since CNPG 1.26, bumping a cluster's imageName to a higher PostgreSQL major triggers an offline in-place upgrade: the operator shuts the cluster down safely, runs pg_upgrade, and brings it back on the new major (PostgreSQL 18 images are supported from CNPG 1.29). Side by side:

StepRailway one-clickCNPG declarative upgrade
TriggerDashboard button + target pickerEdit imageName in the Cluster manifest
Pre-flightGenerated compatibility reportYou, reading the PG 18 release notes and extension changelogs
BackupAutomatic before and afterYour backup schedule (Velero/CNPG scheduled backups) — verify restorability first
DowntimeMinutes, tracked in the dashboardWhole cluster offline during pg_upgrade; duration scales with catalog size
HA handlingPrimary upgraded, replicas rebuiltReplicas shut down with the cluster and rejoin after
Image constraintEligible versioned official imagesNew image must share the old image's OS distro (no bullseye → bookworm jump mid-upgrade)
RollbackLimited window, restores pre-upgrade backup, loses post-upgrade writesYour rehearsed restore procedure — same data-loss shape, but you own the runbook

What the button hides, enumerated honestly, is the interesting part: compatibility checking, backup verification, extension gating, and the rollback rehearsal. PostGIS, pgvector, and friends must each have a PostgreSQL 18 build or the upgrade fails closed.

CNPG automates the pg_upgrade mechanics — the shutdown ordering, the binary invocation, the catalog migration — which is real labor saved over hand-rolled upgrades. It does not automate the judgment: which extensions are ready, whether your backup actually restores, or how long your catalog takes. Budget the rehearsal, not just the manifest edit.


The Monday-morning runbook

Concretely, for the operator who read this far and wants the speedup:

  1. Clone and rehearse. Restore a production backup to a scratch cluster and run the full CNPG image-bump upgrade there first. Time it; that duration is your maintenance window, not a guess.
  2. Gate extensions. List every extension in pg_extension, confirm each ships a PostgreSQL 18 build for your CNPG image variant, and pin versions. One unready extension blocks the whole upgrade.
  3. Pick io_method per node capability. io_uring where checks 1–3 pass, worker everywhere else. Both beat sync on cold reads; neither helps warm or write-bound workloads.
  4. Measure before and after. Capture pgbench read profiles and EXPLAIN (ANALYZE, BUFFERS) output for your five slowest read queries on the old major, then re-run identical workloads after. If the numbers don't move, re-check the checklist before blaming the database.
  5. Keep the rollback path hot. Know exactly which backup restores to which point, and how long the restore takes. Railway's rollback window discards post-upgrade writes; yours will too — the difference is whether you told your team that before the upgrade or after.

Own the machines, own the upgrade

PostgreSQL 18 rewards operators and punishes assumptions: the AIO win is real but workload-shaped, io_uring is fast but gated on kernel, build, and seccomp facts nobody checks until something is slow, and the CNPG path to get there is declarative without being effortless. The good news is that the observability gap is closing — PostgreSQL 19 development is already adding EXPLAIN ANALYZE visibility into AIO behavior through an IO option, so the next upgrade's "did it actually use async reads" question gets a plan-node answer instead of inference from timings.

The managed button will keep getting better; Railway's September release is proof of the direction. But every item in the checklist and runbook above is also knowledge that transfers — to the next major, to the next storage backend, to the 3 AM page the button can't answer. That is the actual trade of operator-owned data: nobody does it for you, and nobody can take the understanding away.

Bex.co is the open-source, AI-native Render alternative — push a git repo, get a running HTTPS service on machines you own. Star the repo on GitHub or deploy your first app today.

Related articles

Run this on infrastructure you own

bex is the open-source, AI-native Render alternative — push a git repo and get a running HTTPS service on your own machines.

Get started with bex