Most Postgres releases are a polite list of incremental improvements. PostgreSQL 18 is not that. It changes how the engine talks to your disk, gives you a primary key type that stops fragmenting your indexes, and quietly removes a class of index you no longer need to create.
If you run a Postgres database behind a Next.js app, a Laravel backend, or an AI agent that hammers it with vector lookups, three of these changes will show up in your latency graphs. Here is what they are, how to turn them on, and which ones you should ignore.
Async I/O: the headline change
For its entire history, PostgreSQL read data from disk one blocking call at a time. It asked the operating system for a page, waited, got it, and asked for the next one. The OS tried to help by guessing ahead — readahead — but the OS does not know what a B-tree traversal looks like, so its guesses were mediocre.
PostgreSQL 18 introduces genuine asynchronous I/O for reads. The backend can now issue many read requests at once and keep working while the kernel fills them. On sequential scans and bitmap heap scans, the reported gains are 2x to 3x, and the effect is largest exactly where it hurts most today: network-attached storage, cloud block volumes, and anything where a single read has real latency.
You control it with io_method:
-- Check what you're running
SHOW io_method;
-- Three options:
-- sync : the old blocking behaviour
-- worker : dedicated I/O worker processes (the default in 18)
-- io_uring: direct kernel ring buffers (Linux 5.1+, build-time flag)worker is the default and works everywhere. It hands read requests to a pool of background processes sized by io_workers. If you are on Linux with a modern kernel and Postgres was compiled with io_uring support, io_method = io_uring skips the worker processes entirely and talks to the kernel through shared ring buffers — less context switching, lower overhead per read.
# postgresql.conf — a reasonable starting point on cloud NVMe
io_method = worker
io_workers = 3
effective_io_concurrency = 16 # was 1 by default in older versionsThe parameter people forget is effective_io_concurrency. It tells the planner how many concurrent reads the storage can absorb. Historically it defaulted to 1, which on an SSD is a lie that costs you real throughput. With async I/O actually implemented, this setting finally means what its name says. Raise it, benchmark, and watch your sequential scans.
A word of caution before you rush: async I/O in 18 covers reads, not writes. If your workload is write-bound — heavy inserts, a busy job queue — this release will not fix that, and the gains you read about in blog posts will not appear in your dashboard.
UUIDv7: the primary key you should have been using
If you generate primary keys as uuid_generate_v4() or in application code with crypto.randomUUID(), you have been paying a tax you may never have measured.
Random UUIDs are, by design, spread uniformly across the key space. Every insert lands in a different part of the B-tree index. That means random page writes, poor cache locality, page splits, and an index that grows faster than it should. It is the classic reason teams eventually migrate off UUIDs and back to bigserial.
UUIDv7 fixes this without giving up global uniqueness. The first 48 bits are a millisecond timestamp; the rest is random. Rows inserted around the same time land next to each other in the index — the same locality you get from an auto-increment integer — while still being safe to generate on the client, in a queue worker, or across shards without coordination.
PostgreSQL 18 ships it natively, no extension required:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
customer_id uuid NOT NULL,
total_cents bigint NOT NULL,
created_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO orders (customer_id, total_cents)
VALUES (uuidv7(), 4500)
RETURNING id;Two bonuses worth knowing. You can pull the timestamp back out of the key, which means the key itself is a free created_at for range queries:
SELECT id, uuid_extract_timestamp(id) AS created
FROM orders
ORDER BY id DESC
LIMIT 10;And uuidv7() accepts an interval offset, which is genuinely useful for backfills and test fixtures where you need identifiers that sort into the past:
SELECT uuidv7(INTERVAL '-1 day');The trade-off is not free: a UUIDv7 leaks approximate creation time to anyone who sees it. For an order ID in an internal admin panel that is fine. For a password-reset token or a public share link, it is not — keep those on v4.
B-tree skip scan: an index you no longer need
Say you have this index, built for a query that filters on tenant first:
CREATE INDEX idx_events ON events (tenant_id, event_type, created_at);Then a new query arrives that filters only on event_type. In PostgreSQL 17 and earlier, that composite index was useless to the planner, because the leading column was not in the predicate. Your options were a sequential scan or a second index — more disk, slower writes, more to maintain.
PostgreSQL 18 adds skip scan to B-tree indexes. When the leading column has low cardinality — a tenant list, a status enum, a country code — the planner can now walk each distinct leading value and skip directly to the matching sections underneath it:
-- This can now use idx_events, even without tenant_id in the WHERE clause
EXPLAIN ANALYZE
SELECT * FROM events
WHERE event_type = 'checkout_completed'
AND created_at > now() - INTERVAL '7 days';The practical consequence: go audit your redundant indexes. Many teams carry two or three overlapping composite indexes purely to cover different leading columns. Some of those can now be dropped, which speeds up every write to the table.
The caveat is cardinality. Skip scan wins when the leading column has few distinct values. If your leading column is user_id with two million distinct values, skipping through all of them is not cheaper than a sequential scan, and the planner will correctly refuse.
Virtual generated columns, and one default change that will bite you
Generated columns existed before 18, but they were always STORED — computed on write and physically saved. PostgreSQL 18 adds VIRTUAL generated columns, computed on read and taking no disk space at all.
CREATE TABLE users (
first_name text NOT NULL,
last_name text NOT NULL,
full_name text GENERATED ALWAYS AS (first_name || ' ' || last_name) VIRTUAL
);Here is the trap: in PostgreSQL 18, VIRTUAL is the default when you omit the keyword. If you have migration files that say GENERATED ALWAYS AS (...) without specifying STORED, their behaviour changes on upgrade — you lose the physical column, and you lose the ability to index it. Grep your migrations before you upgrade, and be explicit:
-- Be explicit. Always.
price_with_tax numeric GENERATED ALWAYS AS (price * 1.19) STORED;Use STORED when you need to index the column or when the expression is expensive. Use VIRTUAL when the value is cheap to compute and you just want it in your SELECT *.
The quality-of-life features
A few smaller changes that will show up in your day-to-day:
RETURNING can see both sides of an update. Previously you got the new row. Now you get both, which removes a whole category of read-modify-write round trips:
UPDATE accounts SET balance = balance - 100
WHERE id = $1
RETURNING old.balance AS before, new.balance AS after;pg_upgrade keeps your planner statistics. In every previous major upgrade, you finished the migration and then sat in a slow, badly-planned database until ANALYZE finished across every table. PostgreSQL 18 carries statistics across the upgrade. For a large database this alone can turn a scary maintenance window into a boring one.
NOT NULL ... NOT VALID. You can add a not-null constraint without a full table scan, then validate it later when traffic is low:
ALTER TABLE foo ADD CONSTRAINT name_nn NOT NULL name NOT VALID;
-- ...later, off-peak:
ALTER TABLE foo VALIDATE CONSTRAINT name_nn;OAuth 2.0 authentication is now built in, which matters if you are moving away from long-lived database passwords toward short-lived tokens issued by your identity provider.
Should you upgrade?
Upgrade if: you are read-heavy on cloud storage (async I/O is a real win), you use random UUIDs as primary keys (UUIDv7 is the reason to go), or you maintain a pile of overlapping composite indexes (skip scan lets you delete some).
Wait if: your workload is write-bound, you depend on extensions that have not certified against 18 yet, or you have generated columns in production and have not audited them for the VIRTUAL default change.
For anyone running Postgres as the backing store for AI features — embeddings in pgvector, agent memory, job queues — the async I/O change is the one to test first. Vector similarity search is read-heavy and scan-heavy, exactly the shape of workload this release was built for. If you are still choosing a vector store, our vector database comparison covers where pgvector wins and where it does not, and our guide to Drizzle versus Prisma covers the ORM layer that sits on top of all this.
Benchmark on a replica, on your data, with your queries. The 3x numbers are real, but they are someone else's 3x.
Need help planning a PostgreSQL upgrade or tuning a database that has outgrown its defaults? Talk to the Noqta team.