PostgreSQL 18 came out on September 25, 2025, and the first minor release, 18.1, followed on November 13. Enough time has passed for the early adopters to report back, and for the rest of us to plan the upgrade. That makes this a good moment for a compact reference.

The release has one headline feature you will not see in your SQL at all: a new asynchronous I/O subsystem. Around it sit several features you will use every day: uuidv7(), virtual generated columns, OLD and NEW in RETURNING, and constraints over time ranges. There are also a few default changes that matter more during an upgrade than any new feature.

This post covers what changed, what each setting means, the SQL you will actually write, and an upgrade checklist. It assumes you already know PostgreSQL; it is not an introduction.

1. What is new, at a glance

Feature What it does Why it matters
Asynchronous I/O (AIO) Backends queue several read requests instead of waiting on each one The project reports up to 3x faster reads from storage in some cases. Benefits sequential scans, bitmap heap scans and vacuum.
uuidv7() Generates timestamp-ordered UUIDs (RFC 9562) UUID primary keys without random B-tree inserts.
Virtual generated columns Computed on read, take no storage. Now the default for GENERATED ALWAYS AS Derived columns without write amplification. Changes the meaning of existing DDL.
OLD / NEW in RETURNING Return the before and after values from INSERT, UPDATE, DELETE, MERGE Audit trails and change detection without triggers or extra queries.
Temporal constraints WITHOUT OVERLAPS for primary keys and unique constraints, PERIOD for foreign keys Database-enforced "no double booking" over ranges.
B-tree skip scan Use a multicolumn index even when the leading column has no condition Fewer redundant indexes.
Statistics survive pg_upgrade Planner statistics carry over to the new major version No slow-query window while ANALYZE catches up.
EXPLAIN ANALYZE shows buffers BUFFERS output is included automatically Every plan you share now shows I/O, not only timing.
OAuth authentication New oauth method in pg_hba.conf, with validator libraries Single sign-on for database logins.
Data checksums on by default initdb enables checksums unless told otherwise Catches silent storage corruption, but affects upgrades (section 7).

2. Asynchronous I/O

Before 18, a backend reading a table issued a read, waited, and issued the next one (with some prefetch hints via posix_fadvise on platforms that support it). With AIO, it can have many reads in flight at once. The gain is largest when storage latency is high: cloud block storage, network disks, cold caches.

Setting Meaning Default
io_method How async I/O is executed: worker, io_uring or sync worker
io_workers Number of I/O worker processes (only used with worker) 3
effective_io_concurrency Concurrent I/O operations a session tries to keep in flight. 0 disables async requests 16
maintenance_io_concurrency Same, for maintenance work such as vacuum 16
io_combine_limit Largest single I/O when adjacent blocks are combined 128kB
io_max_combine_limit Upper bound for io_combine_limit, server start only 128kB

The three io_method values:

Value How it works Use it when
worker Backends hand I/O to a pool of worker processes Default and portable. Start here.
io_uring Uses Linux io_uring directly, no worker hop Linux, PostgreSQL built with --with-liburing, and you have benchmarked it
sync Executes async-eligible I/O synchronously (the old behavior) Troubleshooting, or ruling AIO out as the cause of a regression

io_method can only be set at server start, so it belongs in postgresql.conf:

# postgresql.conf
io_method = worker            # or io_uring on a liburing build
io_workers = 3                # raise on large machines with fast storage
effective_io_concurrency = 16
maintenance_io_concurrency = 16

Observe it rather than guess:

-- What the server is running with
SHOW io_method;
SHOW io_workers;

-- Async I/O handles in use right now (new view)
SELECT * FROM pg_aios;

-- I/O volume per backend type and context; byte columns are new in 18
SELECT backend_type, object, context, reads, read_bytes, writes, write_bytes
FROM pg_stat_io
WHERE reads > 0 OR writes > 0
ORDER BY read_bytes DESC NULLS LAST;

Practical notes:

  • Writes are not async yet. AIO in 18 targets reads (sequential scans, bitmap heap scans, vacuum). Do not expect it to fix write-heavy workloads.
  • "Up to 3x" is a ceiling, not a forecast. A dataset that fits in shared buffers or the OS page cache will see little difference. Measure on your own storage.
  • io_uring is not automatically faster. It removes the worker hop, but container runtimes and some kernels restrict io_uring. The worker default exists for good reasons.
  • More workers is not always better. If pg_aios regularly shows a queue and your storage has spare IOPS, raise io_workers. Otherwise leave it.

3. uuidv7(): time-ordered UUIDs

UUIDv4 values are random. As a primary key, every insert lands on a random page of the B-tree index, which hurts cache hit rates and causes page splits and more WAL. UUIDv7 puts a 48-bit Unix timestamp in milliseconds at the front, followed by more timestamp precision and random bits, so new values sort after old ones and inserts go to the right edge of the index, like a bigint sequence.

Function Returns Notes
uuidv7() Version 7 UUID Unix timestamp in milliseconds, plus a sub-millisecond timestamp fraction, plus random bits
uuidv7(shift interval) Version 7 UUID with the timestamp shifted For backdating or testing. The shifted time must stay within the v7 range.
uuidv4() Version 4 UUID New alias for gen_random_uuid()
uuid_extract_timestamp(uuid) timestamptz Works for v1 and v7, NULL for others
uuid_extract_version(uuid) smallint Which version a value is
CREATE TABLE orders (
    id          uuid PRIMARY KEY DEFAULT uuidv7(),
    customer_id bigint NOT NULL,
    total_cents integer NOT NULL,
    created_at  timestamptz NOT NULL DEFAULT now()
);

INSERT INTO orders (customer_id, total_cents) VALUES (42, 1999) RETURNING id;

-- When was this id minted?
SELECT id, uuid_extract_timestamp(id) AS minted_at, uuid_extract_version(id) AS v
FROM orders
ORDER BY id DESC
LIMIT 5;

When to use what:

Key type Pros Cons
bigint identity Smallest (8 bytes), fastest, readable Guessable, needs the database to issue it, awkward to merge across systems
uuid v4 Unguessable, generate anywhere Random inserts into the index, worse locality
uuid v7 Generate anywhere, index-friendly, roughly time-sortable 16 bytes; leaks creation time to anyone who sees the id

Two cautions:

  • Creation time is visible. Anyone with the id can extract the timestamp. If ids appear in public URLs and creation time is sensitive (sign-up dates, internal volumes), use v4 for the public identifier.
  • Do not use the id as your clock. Sort by created_at for business logic. The id is time-ordered, not a guaranteed source of truth for time, and values generated by application servers depend on their clocks.

You can also generate v7 values in the application: Python 3.14 (October 2025) added uuid.uuid7(), and most languages have a library. Generating in the database keeps a single clock.

4. Virtual generated columns

PostgreSQL 12 introduced stored generated columns. PostgreSQL 18 adds virtual ones and makes them the default.

Kind Computed Storage Can be indexed Logically replicated
VIRTUAL (default in 18) On read None No (error: "indexes on virtual generated columns are not supported") No
STORED On insert or update Like a normal column Yes Yes, via publish_generated_columns (new in 18)
CREATE TABLE line_items (
    id          bigint GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
    unit_cents  integer NOT NULL,
    qty         integer NOT NULL,
    tax_rate    numeric(5,4) NOT NULL DEFAULT 0.21,
    -- virtual: computed when read, no storage, no write cost
    net_cents   integer GENERATED ALWAYS AS (unit_cents * qty),
    -- stored: computed on write, can be indexed
    gross_cents integer GENERATED ALWAYS AS (round(unit_cents * qty * (1 + tax_rate))) STORED
);

CREATE INDEX ON line_items (gross_cents);  -- fine, stored
-- CREATE INDEX ON line_items (net_cents); -- error, virtual

-- If you need an index on the virtual expression, index the expression itself
CREATE INDEX ON line_items ((unit_cents * qty));

Rules that apply to both kinds: the expression can only use immutable functions, cannot use subqueries, and cannot reference another generated column. Virtual columns add one more: the expression may only use built-in functions and types, not user-defined ones.

The upgrade trap: DDL that says GENERATED ALWAYS AS (...) without a keyword now creates a virtual column. Existing columns keep their kind across pg_upgrade or pg_dump, because dumps write STORED explicitly. But migration scripts, ORMs and schema tools that omit the keyword will create virtual columns in 18 and stored ones in 17. If a column must be indexed or replicated, write STORED explicitly. Write VIRTUAL explicitly too, so the intent is visible in code review.

5. OLD and NEW in RETURNING

Before 18, RETURNING gave new values for INSERT and UPDATE and old values for DELETE. Now you can ask for both, with the special aliases old and new.

-- What changed?
UPDATE products
SET price = price * 1.10
WHERE price <= 99.99
RETURNING name, old.price AS old_price, new.price AS new_price,
          new.price - old.price AS price_change;

-- Upsert: was this row inserted or updated?
INSERT INTO inventory (sku, qty) VALUES ('A-100', 5)
ON CONFLICT (sku) DO UPDATE SET qty = inventory.qty + EXCLUDED.qty
RETURNING sku, old.qty AS qty_before, new.qty AS qty_after,
          (old.sku IS NULL) AS was_inserted;

If old or new clashes with a table or column name, rename them with RETURNING WITH (OLD AS o, NEW AS n) ....

Command old is new is
INSERT NULL, except for conflicting rows in ON CONFLICT DO UPDATE The inserted row
UPDATE The row before the update The row after the update
DELETE The deleted row NULL
MERGE Depends on the action taken per row Depends on the action taken per row

Typical uses: write audit rows from a CTE (WITH changed AS (UPDATE ... RETURNING old.*, new.*) INSERT INTO audit ...), emit change events from application code, and tell inserts from updates in an upsert without a second query.

6. Temporal constraints and skip scan

Temporal constraints enforce "no overlap" over a range column. The range column goes last in the key.

CREATE EXTENSION IF NOT EXISTS btree_gist;  -- needed for the scalar part of the key

CREATE TABLE room_bookings (
    room_id  integer NOT NULL,
    during   tstzrange NOT NULL,
    guest    text NOT NULL,
    PRIMARY KEY (room_id, during WITHOUT OVERLAPS)
);

INSERT INTO room_bookings VALUES (101, '[2026-02-01 14:00, 2026-02-03 11:00)', 'Ana');
INSERT INTO room_bookings VALUES (101, '[2026-02-02 14:00, 2026-02-04 11:00)', 'Ben');
-- fails: overlaps the existing booking for room 101

You could do this before with an EXCLUDE USING gist constraint. The difference is that it is now a real primary key or unique constraint, and foreign keys can reference it with PERIOD, checking that the referenced row covers the whole referencing range.

Skip scan lets the planner use a multicolumn B-tree index when the leading column has no equality condition, as long as later columns do. It works best when the leading column has few distinct values.

CREATE INDEX ON events (tenant_id, event_type, created_at);

-- Before 18: often a sequential scan, or a second index on (event_type, ...)
-- In 18: can skip through tenant_id values and use the index
EXPLAIN ANALYZE
SELECT count(*) FROM events WHERE event_type = 'refund' AND created_at > now() - interval '7 days';

Do not drop your other indexes on faith. Run EXPLAIN ANALYZE first; skip scan helps with a low-cardinality leading column and can lose to a dedicated index with a high-cardinality one. Since buffers are now in the output by default, compare shared hit and read counts, not only time.

7. Upgrade checklist: the changes that bite

The release notes list the incompatibilities. These are the ones most likely to matter:

Change What happens What to do
Checksums on by default New clusters from initdb have data checksums. pg_upgrade requires matching settings on old and new clusters Upgrading a cluster without checksums: run initdb --no-data-checksums for the new one, or enable checksums on the old cluster first
Generated columns default to virtual DDL without STORED creates virtual columns Audit migrations; write STORED or VIRTUAL explicitly
MD5 passwords deprecated CREATE ROLE / ALTER ROLE with MD5 passwords emit warnings; removal planned for a future major version Move to scram-sha-256. Silence warnings with md5_password_warnings = off only as a stopgap
VACUUM / ANALYZE include inheritance children Running on a parent now processes children too Use ONLY for the old behavior
COPY FROM CSV and \. \. is no longer an end-of-file marker in CSV files Check scripts that relied on it
Full-text search collation FTS now uses the cluster's default collation provider Reindex full-text indexes after upgrading if the provider is not libc
AFTER triggers run as the queuing role Triggers run as the role active when the event was queued Review triggers in SECURITY DEFINER and role-switching code

pg_upgrade itself got better:

# Check first, in parallel, then upgrade by swapping data directories (new in 18)
pg_upgrade --old-bindir /usr/lib/postgresql/17/bin --new-bindir /usr/lib/postgresql/18/bin \
           --old-datadir /var/lib/postgresql/17/main --new-datadir /var/lib/postgresql/18/main \
           --jobs 4 --check
pg_upgrade ... --jobs 4 --swap

# Statistics are carried over by default; extended statistics are not, so still run:
vacuumdb --all --analyze-in-stages --missing-stats-only

--swap moves directories instead of copying or linking files, which is the fastest mode, but the old cluster is modified destructively and is no longer safe to start once the swap begins. Take a backup first.

Version status as of mid-January 2026: 18.1 is the current minor release, 17 is supported until November 2029, and 13 reached end of life in November 2025. If you are still on 13, you are already unsupported; 18 is a reasonable target.

Why this matters

  • Performance without code changes. AIO speeds up scans and vacuum on slow storage with no query changes. Measure before and after with pg_stat_io.
  • Better UUID keys are now built in. uuidv7() removes the main argument against UUID primary keys, without an extension.
  • Defaults changed under your DDL. Virtual generated columns and default checksums are the two changes most likely to produce surprises in migrations and upgrades.
  • Less application glue. OLD and NEW in RETURNING and temporal constraints move audit and booking logic into a single statement or constraint.
  • Upgrades hurt less. Kept statistics, --swap and parallel checks shorten the downtime window, which removes one more reason to stay on an old major version.

Final Thoughts

PostgreSQL 18 is mostly an infrastructure release. The biggest change, AIO, is invisible in your SQL, and the most useful new functions are small. That is a good sign: the gains come from the engine, and the risks come from a handful of well-documented default changes.

Plan the upgrade around those defaults. Test with a copy of production, check generated columns and checksums, compare EXPLAIN ANALYZE output for your top queries, and keep io_method = sync in mind as a quick way to rule AIO in or out if something regresses.

Sources