UUID as a primary key in Postgres: use uuidv7(), store it as uuid, and measure
- Steps
- 6
- Hands-on
- 15 min
- Reading
- 7 min
- Checked
- Oct 25
The short version
Use a UUID primary key in Postgres when IDs must be made outside the database or must not be guessable, and make it a version 7 stored as the uuid type: id uuid primary key default uuidv7() on PostgreSQL 18 and newer. In our 10-million-row test on PostgreSQL 18.6, uuidv7() keys inserted in 21.08 seconds against 14.09 for a bigint, while random v4 keys took 79.05 seconds and the same v4 stored as text took 132.89.

What you need
- PostgreSQL 18 or newer for the built-in uuidv7() (older versions: see Step 2)
- psql or any SQL client connected as a user that can create tables
- For Step 4: permission to run CREATE EXTENSION pgstattuple (it ships with Postgres)
Part of the article Pick a UUIDv7 library that keeps order: 10 tested, .NET fails 50%. This page answers one question from it in depth.
On this page · 13 sections
- Key numbers
- Before you start: how we tested
- Check your Postgres version
- Declare the key as uuid with a uuidv7() default
- Insert without passing an id
- Measure the table and the index
- Read the creation time when you need it
- Switch an existing v4 table to v7
- What goes wrong
- What this means for you
- Related
- FAQ
- Sources
The "never use UUIDs as keys" advice is a decade old and was about random UUIDs. The fix is in the box now, free, in the database most of the internet already runs. Here is the whole setup, and the numbers from running it.
Key numbers
| What | Number | Source (checked Oct 25 2026) |
|---|---|---|
| Built-in uuidv7() | PostgreSQL 18 and newer | PostgreSQL 18 UUID functions |
| Current release / next | 18.6 (Aug 13 2026) / 19 RC 1 (Oct 15 2026) | versions.json, docs banner |
| Insert 10M rows: bigint / uuidv7 / uuid v4 / text v4 | 14.09 / 21.08 / 79.05 / 132.89 s | our test, median of 3 |
| WAL written by those inserts | 1.69 / 1.82 / 2.99 / 7.93 GB | our test |
| Primary key index size | 214 / 301 / 389 / 729 MB | our test |
| Index leaf pages full, ordered vs random keys | 90.0% vs 69.7% | our test, pgstatindex |
| 100,000 random lookups by key | 0.23 / 0.39 / 0.74 / 0.91 s | our test |
| uuid type size | 16 bytes (text form: 36 characters) | PostgreSQL uuid type |
Before you start: how we tested
We ran PostgreSQL 18.6 on a Windows 11 desktop with default settings (128 MB shared_buffers, fsync on). For each key type we created the same table (id, created_at timestamptz, a 40-character payload), inserted 10 million rows in 10,000-row statements with one commit each, then measured insert time, WAL generated (pg_wal_lsn_diff), table and index size, pgstatindex density and the time for 100,000 lookups by key in batches of 1,000. Three full runs; we report the median. The raw rows for all 12 runs are in the data file on this page.
Step 01: Check your Postgres version
select version();
select uuidv7() as v7, uuidv4() as v4;
If uuidv7() exists, you are on 18 or newer. It builds a 48-bit Unix timestamp in milliseconds, a sub-millisecond fraction and random bits, per the PostgreSQL 18 docs. uuidv4() is the same as gen_random_uuid().
Step 02: Declare the key as uuid with a uuidv7() default
create table orders (
id uuid primary key default uuidv7(),
created_at timestamptz not null default now(),
total_cents bigint not null
);
Two choices matter here. The type: uuid stores 16 bytes; a text column stores the 36-character string plus a header, and in our test that made the primary key index 729 MB against 389 MB for the same v4 values as uuid. The version: v7 starts with the time, so new keys arrive in order.
On PostgreSQL 17 or older there is no built-in v7. Generate it in your app (we tested 10 libraries and some fail to keep order) or install pg_uuidv7 by Florian Boulnois, a small MPL-2.0 extension.
Step 03: Insert without passing an id
Let the default fill the key and ask for it back with RETURNING:
Our psql session, Oct 25 2026, 5:24 PM ET. Three IDs made within 2 milliseconds still sort in creation order.
The three IDs share their first 12 hex digits, the millisecond timestamp, and still sort in insert order because Postgres fills the next bits with a sub-millisecond fraction.
Step 04: Measure the table and the index
create extension if not exists pgstattuple;
select pg_size_pretty(pg_relation_size('orders')) as heap,
pg_size_pretty(pg_relation_size('orders_pkey')) as pk_index;
select avg_leaf_density, leaf_fragmentation from pgstatindex('orders_pkey');
This is where random keys show their cost. A B-tree (the index behind every primary key) keeps keys sorted in pages. Ordered keys always go on the last page, which fills to the 90% default and moves on. Random keys land on any page; when one is full it splits in two half-empty pages.
The text-v4 table from our third run, measured right after the inserts. Its index leaves are 69.7% full; the uuidv7() index measured 90.0%. Oct 25 2026.
WAL is what your replicas, backups and disk all have to carry. Random keys wrote 1.64x the WAL of uuidv7(); random text keys 4.35x. Our test, Oct 25 2026, median of 3 runs.
Every split writes extra pages to the write-ahead log, and after each checkpoint the first change to a page logs the whole page. Scatter writes over thousands of pages and that adds up: the same 10 million rows cost 2.99 GB of WAL with v4 keys and 1.82 GB with v7.
Step 05: Read the creation time when you need it
select id, uuid_extract_timestamp(id), uuid_extract_version(id) from orders;
A v7 key is also a timestamp, which is handy for debugging and pagination. Postgres's docs warn the extracted time "is not necessarily exactly equal to the time the UUID was generated" when another system made the ID. Our timestamp guide shows where it lies.
Step 06: Switch an existing v4 table to v7
alter table orders alter column id set default uuidv7();
-- optional, to compact the old random part of the index:
reindex index concurrently orders_pkey;
Old rows keep their v4 IDs, so nothing that references them breaks; v7 and v4 values live happily in one uuid column. New rows now append to the right edge of the index. REINDEX CONCURRENTLY rebuilds the index packed without blocking writes, at the cost of a second copy on disk while it runs.
What goes wrong
- Storing UUIDs as text or varchar. 1.9x the index size and 1.7x the insert time of the same values in a uuid column in our test. Convert with
alter table ... alter column id type uuid using id::uuid(it rewrites the table, so plan a window). - Assuming v7 hides anything. It publishes the creation time to the millisecond. For invite codes, password reset tokens or anything where time is private, keep v4 or a random token.
- Generating v7 in an app library that does not keep order. Some libraries reset or skip the counter within a millisecond. Our library test found one that broke order on 50% of IDs.
- Expecting UUIDs to be as cheap as bigint. They are not: even v7 took 7 seconds longer on 10 million rows and built a 40% bigger index. Pick a UUID for what it buys you (IDs made anywhere, no central counter, not guessable), not by habit.
What this means for you
You do not need a vendor's ID service, a sequence server or a managed database feature to get globally unique, fast keys. One default clause in free software does it. Use uuid plus uuidv7(), measure your own index with pgstatindex, and keep v4 only where time must stay hidden. Building on Supabase (which is Postgres)? Our $49 Launch Check checks your app for databases anyone can read before your users find them; if a problem in what we checked turns up after the report, we write the fix at no extra cost.
Related
- We tested 10 UUIDv7 libraries: the parent test.
- UUIDv7 vs UUIDv4: the same effect measured in SQLite.
- Snowflake ID vs UUID and UUIDv7 vs ULID.
- How to extract the timestamp from a UUIDv7.
- UUIDv7 generator and Unix timestamp converter.
FAQ
Should I use UUID as a primary key in Postgres?
Use one when IDs must be created outside the database, merged across systems or hidden from guessing; a bigint identity is still the smallest and fastest key. If you use a UUID, make it version 7 and store it as the uuid type. In our 10 million row test on PostgreSQL 18.6, uuidv7() keys took 21.08 seconds to insert against 14.09 for bigint, while random version 4 keys took 79.05.
Is UUID as a primary key slow in Postgres?
Random version 4 UUIDs are. Each new key lands at a random spot in the B-tree index, which splits pages, leaves them about 70% full and writes more WAL. In our test v4 inserts were 3.75 times slower than v7, wrote 1.64 times the WAL and built a 29% bigger index.
Does Postgres have uuidv7()?
Yes, from PostgreSQL 18. It also added uuidv4() as an alias of gen_random_uuid() and uuid_extract_timestamp() for versions 1 and 7. On older versions you can generate v7 IDs in application code or install the pg_uuidv7 extension.
Should I store a UUID as text or uuid in Postgres?
Always uuid. The uuid type is 16 bytes; the text form is 36 characters plus a length header. In our test a text primary key holding v4 UUIDs made the index 729 MB against 389 MB for the uuid type, and inserts took 132.89 seconds against 79.05.
Does uuidv7 leak information?
It reveals when the row was created, to the millisecond: anyone with the ID can call uuid_extract_timestamp() or decode the first 12 hex digits. Use v4 for IDs where creation time is private.
Sources
All checked October 25, 2026.
PostgreSQL documentation
- PostgreSQL, UUID functions (version 18): uuidv7() with its millisecond plus sub-millisecond layout, uuidv4() and gen_random_uuid(), uuid_extract_timestamp() and its accuracy note, uuid_extract_version().
- PostgreSQL, UUID type (version 18): the 128-bit uuid type, RFC 9562.
- PostgreSQL, pgstattuple and pgstatindex: avg_leaf_density and leaf_fragmentation.
- PostgreSQL, versions.json: 18.6 released August 13, 2026; PostgreSQL 19 RC 1 announced October 15, 2026.
- IETF, RFC 9562: Universally Unique IDentifiers, May 2024: UUID version 7.
Tools
- Florian Boulnois, pg_uuidv7: UUIDv7 extension for older Postgres versions, MPL-2.0.
Our data
- Our benchmark: PostgreSQL 18.6 (EDB Windows x64 binaries), default configuration, shared_buffers 128 MB, fsync on, Windows 11 desktop, October 25, 2026 5:11 to 5:24 PM ET. 10 million rows per key type in 10,000-row committed statements, 3 runs, medians reported; WAL from pg_wal_lsn_diff, sizes from pg_relation_size, density from pgstatindex, 100,000 lookups in batches of 1,000. All 12 runs in data.csv on this page.
- Terminal captures: our psql sessions on the same server, October 25, 2026.
Images
- Charts and terminal captures by Tensorust from the runs above.
Do this next
Every number on this page links to its source and was checked on Oct 25, 2026. Spot an error or a newer figure? Email matt@tensorust.com and it gets fixed. Related: UUIDv7 generator, Unix timestamp converter.
Read next
- Article · Oct 23, 2026Pick a UUIDv7 library that keeps order: 10 tested, .NET fails 50%
- Article · Oct 18, 2026Build a database in Rust: AI passed 866K tests, then failed ROLLBACK
- Explainer · Oct 25, 2026Gumroad fees, explained with real numbers
- Guide · Oct 25, 2026Cold email deliverability: set up SPF, DKIM and DMARC, then send slower than you think
Get the next one by email.
What my AI agents tried this week, with the real numbers: what worked, what flopped, what made money. One email when there is something worth reading.
No spam, never sold. Every email has a one-click unsubscribe.