Postgres 18: Async I/O, uuidv7, and Whether It Helps You
Postgres 14 stops getting fixes on 12 November, 19 arrives in September, and 18 is the version to land on. What actually changed in the storage layer, and how to tell in advance whether you will feel any of it.
Two clocks make this decision for you, and one of them just started ringing.
Postgres 13 went end of life on 13 November 2025 and RDS ended standard support for 13.x on 28 February 2026, which means anyone still on it is enrolled in Extended Support and paying for the privilege right now. Postgres 14 is next: community support ends on 12 November 2026, about twelve weeks out. RDS keeps 14 under standard support until 28 February 2027, so the managed calendar gives you a quarter of cover past the community one, and no more.
The other clock is 18 itself. It went GA on 25 September 2025 and is on 18.6 as of this month, which is exactly the maturity you want to upgrade into. Postgres 19 is planned for September 2026, with beta 3 out on 13 August. That is a month away, and it is not the version to land a production database on this year. Versions checked on the postgresql.org release pages on 22 August 2026.
So the question is not whether to upgrade. It is how far to jump and what breaks when you land.
Skip the intermediate versions
If you are on 13 or 14, go straight to 18. pg_upgrade crosses multiple major versions in one run, so climbing the ladder one rung at a time buys nothing.
The cost of a major upgrade sits almost entirely in testing: extensions, drivers, query plans, application behavior. That cost is nearly identical whether you go 14 to 16 or 14 to 18. Landing on the newest version means the longest runway before you do this again, and 18 runs to November 2030.
The one case for stopping short is an extension your stack cannot live without that supports 17 but not 18. Then land on the newest version everything supports and revisit. Which is the part teams underestimate, so it gets its own section below.
Async I/O, and what it actually accelerates
Before 18, when a backend needed a page that was not in shared buffers, it issued a read and blocked until the operating system came back with the data. One page, wait, repeat. On an I/O-bound scan that is a process sitting idle in front of a disk.
Postgres 18 adds an async I/O subsystem so a backend can have several reads in flight and keep working. It helps sequential scans, bitmap heap scans, and vacuum: the operations that chew through pages nobody has cached. Independent benchmarks published around the release showed real gains on read-heavy workloads, with the size of the win varying enough that quoting a multiple would be dishonest.
The control is a new io_method setting, and it can only be set at server start. Three values: worker (background processes handle the I/O, and this is the default, with io_workers defaulting to 3), io_uring (the Linux kernel interface directly, usually fastest), and sync (the old blocking behavior, which is your rollback switch).
io_uring has to be compiled in with --with-liburing, and plenty of packaged builds shipped without it. If you set it and get invalid value for parameter "io_method" with a hint listing only sync, worker, that is what happened. On a managed service you take whatever the provider enabled and the setting is not yours to pick.
Whether the win is real for your database
Here is the part the release-week blog posts skip. Async I/O speeds up disk reads. If your working set already fits in RAM and lives in shared buffers, your queries were not waiting on the disk, so there is nothing to accelerate.
It pays off on large tables you scan or vacuum that do not fit in memory, on analytical queries doing big sequential or bitmap heap scans, on vector search over HNSW indexes bigger than cache, and on anything where the disk rather than the CPU is the bottleneck. It is a non-event on a 20 GB database with 64 GB of RAM serving point lookups by primary key. Your data is already hot.
You can tell which one you are before upgrading rather than after. Look at your buffer cache hit ratio and your I/O wait. At 99.9% hits and near-zero wait, async I/O is not your win, and the November deadline still is, so upgrade anyway and take the rest.
The pgvector case is worth calling out because the fit is unusually good. Traversing an HNSW graph means reading nodes scattered across the index, which is exactly the shape async I/O was built for. If your vector index outgrew cache, this is the release where that stops hurting as much.
uuidv7 is the new default worth adopting
If you have ever used a v4 UUID as a primary key you have felt this without naming it. Random UUIDs scatter inserts across the whole B-tree, every insert lands in a random leaf page, cache locality dies and the index bloats. It is why so many teams stayed on bigint sequences while quietly wishing they had not.
uuidv7() puts a 48-bit timestamp at the front and randomness behind it, so values generated close together sort close together and inserts stay near the right edge of the index. You keep the uniqueness and the un-guessability without paying for it on every write. 18 also adds a uuidv4() alias so the schema says which one you meant.
For a new table on 18, reach for uuidv7() over gen_random_uuid() unless you have a reason not to.
Virtual generated columns, and their fine print
Generated columns used to be STORED only: computed on write, occupying disk. In 18 a generated column is virtual by default, computed on read, costing no storage and no write amplification. For derived fields you read occasionally that is the better trade and now you get it without asking.
The restrictions are real enough to catch you. A virtual generated column cannot have a user-defined type, and its expression can only use built-in functions and types, so anything referencing your own function has to be STORED. Logical replication carries stored generated columns only. And a column computed on read is not a column you can index, so if the point was to index the derived value, say STORED explicitly.
That default flip matters most on upgrade if someone on your team writes new DDL assuming the old behavior. The old tables are unaffected. The new ones will quietly be a different thing.
There is more in the release: OAuth authentication, better EXPLAIN output, planner work around indexes, and upgrades that preserve planner statistics. Async I/O and uuidv7 are the two that change how you would design and run things.
What breaks
The core team is careful about compatibility. Your extensions are somebody else’s project.
Extensions are where upgrades actually die. A major version bump can require every extension to be rebuilt against the new server, and sometimes to be changed. Before you touch production, go through your \dx output and confirm 18 builds exist for all of it: pgvector, PostGIS, TimescaleDB, pg_partman, whatever else is in there. TimescaleDB has historically trailed new majors because it hooks deep into the executor. Check each extension’s own release notes, and on a managed service check which extension versions that provider ships for 18 specifically, because it is often behind upstream.
Drivers and ORMs are usually fine. node-postgres, psycopg3, pgx, and the JDBC driver all handle a major bump, but pin versions before you cut over rather than after. Prisma, Drizzle, and Django’s ORM do not care much about the server version directly. Their migration tooling sometimes does.
Query plan regressions are the subtle one. The planner changed. Almost everything gets the same or better plans, and then there is a handful where a slightly different cost estimate flips an index scan to a sequential scan and one endpoint gets 40 times slower. A schema diff will not show you these. Capture a slow-query baseline before the upgrade from pg_stat_statements or your APM, compare after, and put ANALYZE in the cutover runbook so the planner has fresh statistics from minute one.
Two migration paths
pg_upgrade relinks or copies the data files in place rather than dumping and reloading, so downtime is minutes. It is right for most databases. 18 improved statistics handling during the upgrade, so you are less likely to eat terrible plans while stats rebuild, and I would still run ANALYZE immediately after.
Logical replication is the near-zero-downtime path: stand up an 18 instance, replicate into it, let it catch up, cut the application over. More moving parts and more setup, and the only real answer when you cannot take a few minutes. On RDS and Aurora the managed blue/green deployment wraps this pattern and is worth using if you are there.
Either way, rehearse on a clone with production-shaped data, time it, and write the rollback plan before you need it. For pg_upgrade that means keeping the old cluster intact until you have verified the new one, and remembering that io_method = sync is the escape hatch if async I/O behaves oddly on your storage.
If you are still on 13 or 14 on RDS
Two mechanics are worth knowing before you plan the quarter.
Extended Support charges start the day after the end of standard support date, and they apply to standby instances in Multi-AZ deployments too, which doubles the line for anyone who read the per-instance rate and multiplied by the wrong number. They stop when you upgrade to a version under standard support, or when you delete the database.
And the opt-out is a live grenade. Setting EngineLifecycleSupport to open-source-rds-extended-support-disabled on an instance already past its standard support date makes RDS automatically upgrade it to the next supported major version. That is a fine way to stop the meter and a memorable way to find out your extensions were not ready. Extended Support also runs at most three years, after which AWS upgrades you regardless.
Treat the surcharge as a bought quarter, not a runway. The teams that get hurt are the ones who pay it for two years and then face a bigger version gap than the one they were avoiding.
What I would do this week
Spin up an 18 instance from a snapshot, point a copy of your traffic at it, and diff the query plans against production. That costs an afternoon and it is where the surprises are: an extension that will not build, a plan that flipped, a generated column that is now virtual and cannot be indexed.
Then look at your cache hit ratio, so that when someone asks whether async I/O made things faster you can answer with a number you took before the upgrade rather than a shrug.