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:
docker run --name pg18 -e POSTGRES_PASSWORD=dev -p 5432:5432 -d postgres:18-alpine
docker exec -it pg18 psql -U postgres1. 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.
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; name | price | price_with_vat
----------+--------+----------------
Keyboard | 100.00 | 119.00
Mouse | 50.00 | 59.50Adding a virtual column doesn't rewrite the table, so it's cheap on large tables too.
Virtual or stored?
VIRTUAL | STORED | |
|---|---|---|
| Disk space | None | Stored in every row |
| Cost on write | None | Calculated on insert and update |
| Cost on read | Calculated each time | None |
| Can be indexed | No | Yes |
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:
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:
update products
set price = 80
where name = 'Keyboard'
returning old.price as before, new.price as after, new.price_with_vat; before | after | price_with_vat
--------+-------+----------------
100.00 | 80.00 | 95.20This 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
oldandnewto write an audit entry in the same statement.