Random UUIDs solved one problem and quietly made another one worse.
They are great when your app needs IDs that can be generated without waiting on a single database sequence. They are less great when every new row lands in a random spot in your primary-key index. PostgreSQL can handle that, but the write pattern is not free as tables grow.
PostgreSQL 18 gives you a better default for many app tables: native uuidv7().
UUIDv7 keeps the useful part of UUIDs, which is decentralized ID generation, but puts timestamp information near the front of the value so IDs sort roughly by creation time. For events, orders, messages, audit rows, queue jobs, and plenty of normal web-app records, that is a friendlier shape than UUIDv4.
What PostgreSQL 18 Added
PostgreSQL already had the uuid type and gen_random_uuid() for UUIDv4. PostgreSQL 18 adds native generation for UUIDv7:
gen_random_uuid() -- version 4, random
uuidv4() -- version 4, random
uuidv7() -- version 7, time-ordered
The PostgreSQL docs describe uuidv7() as a time-ordered UUID generator. Its timestamp is based on Unix time with millisecond precision, plus sub-millisecond timestamp data and random data. It also accepts an optional interval shift:
select uuidv7();
select uuidv7(interval '1 hour');
Most application schemas should use the no-argument form. The shift option is there, but I would not hide shifted timestamps inside a normal primary-key default unless the reason is extremely clear.
The practical win is simple: PostgreSQL 18 can generate sortable UUIDs in the database. You do not need a trigger, a third-party extension, or an application-side UUID library just to get the ID shape.
Use It In A New Table
For a new PostgreSQL 18 table, the schema can be the following:
create table app_events (
id uuid primary key default uuidv7(),
account_id uuid not null,
event_type text not null,
payload jsonb not null default '{}'::jsonb,
created_at timestamptz not null default now()
);
That gives you:
- a UUID-shaped ID your app can expose
- database-side generation for inserts that omit the ID
- roughly time-ordered primary-key values
- an explicit
created_at column for business logic
Keep the created_at column.
PostgreSQL 18 can extract a timestamp from a UUIDv7 value, but your primary key should not become your audit model. A real timestamp column is easier to query, easier to index with other columns, easier to backfill, and easier to explain when data arrives late.
Insert Rows Normally
Once the default is in place, inserts do not need to mention id:
insert into app_events (account_id, event_type, payload)
values (
'9df6f6f4-67d8-4d95-80d8-c2a5ccf31ef6',
'checkout.started',
'{"cart_total": 129.50}'::jsonb
)
returning id, created_at;
If a service needs to generate IDs before the insert, you can still pass an explicit UUID:
insert into app_events (id, account_id, event_type)
values (
'019535d9-3df7-79fb-b466-fa907fa17f9e',
'9df6f6f4-67d8-4d95-80d8-c2a5ccf31ef6',
'checkout.started'
);
The column stores any valid UUID. The default only controls what happens when the insert omits the ID.
Verify The IDs
PostgreSQL 18 includes helpers for inspecting UUIDs:
select
id,
uuid_extract_version(id) as uuid_version,
uuid_extract_timestamp(id) as uuid_time
from app_events
order by created_at desc
limit 5;
For UUIDv7 values, uuid_extract_version(id) returns 7. uuid_extract_timestamp(id) extracts a timestamp from version 1 or version 7 UUIDs. For UUIDv4 values, timestamp extraction returns null.
That gives you an easy migration check:
select uuid_extract_version(id), count(*)
from app_events
group by 1
order by 1;
On a brand-new table, you should see version 7. On an existing table where you changed the default, you may see a mix of older UUIDv4 rows and newer UUIDv7 rows.
Do Not Convert Existing UUIDv4 IDs
UUIDv4 and UUIDv7 both fit in PostgreSQL's uuid type, but they are not two display formats for the same value. They are different generation schemes.
Do not try to "convert" existing UUIDv4 primary keys into UUIDv7. A random UUIDv4 does not contain the original creation timestamp. Replacing it with a UUIDv7 means assigning a brand-new ID, which means updating foreign keys, external references, caches, URLs, logs, and every integration that has learned the old value.
For an existing table, pick one of these safer paths:
- Leave existing IDs alone and change the default so new rows use UUIDv7.
- Add a separate UUIDv7 column for new ordering or correlation needs.
- Re-key only during a deliberate migration where every reference is controlled.
Changing only the default is straightforward:
alter table app_events
alter column id set default uuidv7();
That affects future inserts. Existing rows keep their current IDs.
Use UUID Ordering Carefully
Because UUIDv7 sorts by timestamp material first, this can be useful for rough recent-row browsing:
select *
from app_events
order by id desc
limit 50;
But I would still write product logic against created_at:
select *
from app_events
where account_id = $1
and created_at >= now() - interval '7 days'
order by created_at desc;
Then index the query you actually run:
create index app_events_account_created_at_idx
on app_events (account_id, created_at desc);
UUIDv7 improves the shape of your primary key. It does not replace thoughtful secondary indexes. PostgreSQL's own index docs say indexes speed retrieval but add overhead, so build indexes around real access patterns instead of adding them out of habit.
Watch The Timestamp Leak
UUIDv7 values are not just random-looking strings. They contain timestamp data. That is part of why they sort well.
For most application IDs, that is fine. An order ID, event ID, message ID, or task ID already tends to reveal timing through the UI, API, or surrounding data.
Pause before using UUIDv7 for:
- password reset tokens
- invite tokens
- secret one-time links
- IDs where creation time is sensitive
- records where timing can leak business volume
For secrets, use random tokens generated for that purpose. A primary key is not an access token.
When UUIDv7 Is A Good Default
UUIDv7 is a strong fit when:
- multiple services may create IDs
- you want UUID-shaped public IDs
- inserts are mostly append-like
- recent-row browsing is common
- you are starting a new PostgreSQL 18 table
- UUIDv4 index locality has become annoying
It is less compelling when:
- a simple
bigint generated by default as identity works fine
- the ID must not reveal timing information
- the table already has stable UUIDv4 references everywhere
- your ordering requirements need exact domain timestamps
My rule: for new PostgreSQL 18 application tables that need UUIDs, start with uuidv7() unless you have a specific reason not to.
A Practical Checklist
For a new table:
create table messages (
id uuid primary key default uuidv7(),
conversation_id uuid not null,
sender_id uuid not null,
body text not null,
created_at timestamptz not null default now()
);
For an existing UUID table:
alter table messages
alter column id set default uuidv7();
Verify new rows:
insert into messages (conversation_id, sender_id, body)
values (
'11111111-1111-4111-8111-111111111111',
'22222222-2222-4222-8222-222222222222',
'hello'
)
returning id, uuid_extract_version(id), uuid_extract_timestamp(id);
Check the query plan for the thing your app actually does:
explain analyze
select *
from messages
where conversation_id = '11111111-1111-4111-8111-111111111111'
order by created_at desc
limit 50;
If that query scans too much, add the index for that access pattern. Do not assume the primary key fixes every lookup.
The Bottom Line
UUIDv7 is not magic. It is a better UUID default.
PostgreSQL 18 makes it easy to use:
id uuid primary key default uuidv7()
That one line gives you database-side generation, UUID compatibility, and friendlier ordering for write-heavy tables.
Use it for identifiers. Keep real timestamps. Index real queries. Do not use IDs as secrets.
Sources