Skip to main content

What's New in PostgreSQL 18: A Developer's Perspective

Tianzhou · Apr 30, 2026

PostgreSQL 18 shipped on September 25, 2025. Every operator-facing writeup leads with the new asynchronous I/O subsystem, and fair enough, it is the big architectural change. But if you write application code rather than tune the database, the AIO is not the part you will feel day to day. Five smaller features are. Each one retires a workaround you have probably been carrying for years: generating UUIDv7 in the app, faking virtual columns, running a SELECT next to every UPDATE just to see what changed, remembering to type BUFFERS, and grepping four catalogs to answer one permission question.

Native UUIDv7 support

uuidv7() returns a UUID version 7: random in its low bits, timestamp-ordered overall. The ordering is the whole point. It makes UUIDv7 viable as a primary key, because inserts cluster instead of scattering, which means fewer B-tree page splits and better cache locality than UUIDv4, while keeping the global uniqueness an integer sequence can never give you.

-- Generate a UUIDv7
SELECT uuidv7();

-- Create a table using UUIDv7 as primary key
CREATE TABLE orders (
    id UUID PRIMARY KEY DEFAULT uuidv7(),
    customer_id INT,
    total DECIMAL(10,2),
    created_at TIMESTAMP DEFAULT NOW()
);

We argued a while back that the industry would converge on UUIDv7 as the default primary key for most new applications. Until now Postgres did not help you get there. uuid-ossp never shipped UUIDv7, the few extensions that did were rarely available on managed services, so most teams generated it in the application layer. Native support deletes that out-of-database step and makes the right default the easy one.

VIRTUAL generated columns

PostgreSQL 12 shipped generated columns with STORED only, which always felt backwards: it picked the expensive default. Adding one rewrote the entire table and then spent disk on every derived value forever.

PostgreSQL 18 adds VIRTUAL and makes it the default. Virtual columns compute on read, not on write, which is the behavior most other major databases already use.

-- Create table with virtual generated column
CREATE TABLE employees (
    id SERIAL PRIMARY KEY,
    first_name TEXT NOT NULL,
    last_name TEXT NOT NULL,
    salary DECIMAL(10,2),
    -- VIRTUAL is the default and can be omitted
    annual_salary DECIMAL(12,2) GENERATED ALWAYS AS (salary * 12) VIRTUAL
);

-- Insert data (generated columns are computed automatically)
INSERT INTO employees (first_name, last_name, salary) VALUES
    ('John', 'Doe', 5000.00),
    ('Jane', 'Smith', 6500.00);

SELECT first_name, last_name, salary, annual_salary
FROM employees;

The rule of thumb: pick VIRTUAL for cheap expressions on lightly read columns, pick STORED when the expression is expensive enough that paying for it on every read costs more than the disk it would occupy. The hard limit most schemas hit first is that VIRTUAL columns cannot be indexed. If you need an index on the derived value, you are back to STORED.

One more caveat if you replicate. PostgreSQL 18 adds logical replication for stored generated columns (commit, docs). Virtual columns still do not replicate.

OLD and NEW values in RETURNING

Anyone who has written an audit trail knows the dance: SELECT the row, run the UPDATE, and hope nothing slipped in between. RETURNING now exposes old and new aliases, so a single statement hands you the row before and after the change. No second query, no trigger, no race window.

UPDATE

-- Update prices and see both old and new values
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;

INSERT ... ON CONFLICT

-- Upsert with change tracking
INSERT INTO products (name, price) VALUES ('Widget', 25.00)
ON CONFLICT (name) DO UPDATE SET price = EXCLUDED.price
RETURNING
    name,
    old.price AS previous_price,
    new.price AS current_price,
    (old.price IS NULL) AS is_new_record;

DELETE

-- Track what was deleted
DELETE FROM products
WHERE price < 10.00
RETURNING
    old.name AS deleted_product,
    old.price AS deleted_price;

The whole class of audit pipelines that paired a SELECT with the mutating statement now collapses into the mutating statement alone. The ON CONFLICT case is the nicest one: (old.price IS NULL) tells you in the same row whether you just inserted or updated.

EXPLAIN ANALYZE with BUFFERS by default

EXPLAIN ANALYZE now reports buffer usage without the explicit BUFFERS option. This should have been the default a decade ago. Buffer counts are exactly the numbers that tell you whether you are looking at a slow plan or a slow disk, and until now they were hidden behind a flag everyone forgot to type.

postgres=# EXPLAIN ANALYZE SELECT * FROM users WHERE age > 25;

-- PostgreSQL 18: BUFFERS included automatically
 Seq Scan on users  (cost=0.00..18.75 rows=125 width=64) (actual time=0.029..0.087 rows=178 loops=1)
   Filter: (age > 25)
   Rows Removed by Filter: 89
   Buffers: shared hit=12
 Planning:
   Buffers: shared hit=156 read=3
   I/O Timings: shared read=0.024
 Planning Time: 0.512 ms
 Execution Time: 0.734 ms

pg_get_acl() for permission inspection

If you have ever lost an afternoon to ERROR 42501: permission denied, this one is for you. pg_get_acl() retrieves the Access Control List for any database object through a single function.

Before, answering "who can touch this object" meant knowing which catalog held the answer, pg_class for tables, pg_proc for functions, pg_namespace for schemas, pg_attribute for columns, each with its own ACL format. You had to remember the map before you could read it.

postgres=# SELECT
    (pg_identify_object(s.classid,s.objid,s.objsubid)).*,
    pg_catalog.pg_get_acl(s.classid,s.objid,s.objsubid) AS acl
FROM pg_catalog.pg_shdepend AS s
JOIN pg_catalog.pg_database AS d
    ON d.datname = current_database() AND
       d.oid = s.dbid
JOIN pg_catalog.pg_authid AS a
    ON a.oid = s.refobjid AND
       s.refclassid = 'pg_authid'::regclass
WHERE s.deptype = 'a';
-[ RECORD 1 ]-----------------------------------------
type     | table
schema   | public
name     | testtab
identity | public.testtab
acl      | {postgres=arwdDxtm/postgres,foo=r/postgres}

One function, any object type, one ACL format. That is the kind of plumbing you only notice when it is missing.

Parting Thought

None of these five will headline a conference talk. The AIO will. But keynote features are the ones you read about, and these are the ones you actually use. Each quietly deletes code you have been maintaining for years, the app-side UUID generator, the shadow SELECT, the trigger you wrote to capture a before-image. A release that removes your code instead of asking you to write more is the better kind of release.

Further Readings

Back to blog

Explore the standard for database development