NewValidate LLM output with Zod and retry when it's wrong →
w3/w3-tutorials

Postgres 18: three features you can use today

Virtual generated columns, uuidv7() primary keys and before-and-after values in RETURNING, each with SQL you can run.

Updated Nov 8, 20263 min readIntermediatepostgres 18.6

What you'll build

A small products table that uses three Postgres 18 features you can adopt on day one: virtual generated columns, uuidv7() primary keys, and old and new in RETURNING. Each one removes code you would otherwise write in your application.

You only need a Postgres 18 server. Docker is the quickest way to get one:

terminal
docker run --name pg18 -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:18-alpine
docker exec -it pg18 psql -U postgres

1. Virtual generated columns

A generated column is calculated from other columns. Earlier versions could only store the result on disk. Postgres 18 adds VIRTUAL columns, which are calculated when you read them, and makes virtual the default.

schema.sql
create table products (
  id uuid primary key default uuidv7(),
  name text not null,
  price numeric(10,2) not null,
  price_with_vat numeric(10,2) generated always as (price * 1.19) virtual
);

insert into products (name, price)
values ('Keyboard', 100), ('Mouse', 50);

select name, price, price_with_vat from products order by id;
output
   name   | price  | price_with_vat
----------+--------+----------------
 Keyboard | 100.00 |         119.00
 Mouse    |  50.00 |          59.50

Adding a virtual column doesn't rewrite the table, so it's cheap on large tables too.

Virtual or stored?

VIRTUALSTORED
Disk spaceNoneStored in every row
Cost on writeNoneCalculated on insert and update
Cost on readCalculated each timeNone
Can be indexedNoYes

2. Time-ordered IDs with uuidv7()

Random UUIDs from gen_random_uuid() land all over the index, which makes inserts on large tables slower. A version 7 UUID starts with a timestamp, so new rows sort near each other. You also get the creation time for free:

sql
select
  id,
  uuid_extract_version(id) as version,
  uuid_extract_timestamp(id) as created_at
from products
limit 1;

One trade-off: anyone who can see the ID can read when the row was created. If that matters, keep a random public ID as well.

3. Before and after values in RETURNING

RETURNING used to show only one version of the row. Now you can ask for both:

sql
update products
set price = 80
where name = 'Keyboard'
returning old.price as before, new.price as after, new.price_with_vat;
output
 before | after | price_with_vat
--------+-------+----------------
 100.00 | 80.00 |          95.20

This is useful for audit logs, because you no longer need to read the row first or write a trigger. Notice that the virtual column already reflects the new price.

Next steps

  • Replace a calculated value in your application code with a virtual column.
  • Switch the default on a new table to uuidv7().
  • Use old and new to write an audit entry in the same statement.