Email me

Guide · 7 min read

Filed under App development Guides

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.

Horizontal bar chart titled Random UUID keys made inserts 3.75x slower. Seconds to insert 10 million rows in 10,000-row commits on PostgreSQL 18.6, median of 3 runs: bigint identity 14.09, uuid with uuidv7() 21.08, uuid with gen_random_uuid (version 4) 79.05, text holding a version 4 UUID 132.89.
Same table, same 10 million rows, four primary key types. Time-ordered uuidv7() keys cost 7 seconds over a plain bigint; random v4 keys cost 58 more than that. Our test on PostgreSQL 18.6, Oct 25 2026, median of 3 runs. Chart by Tensorust from our own benchmark.

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
  1. Key numbers
  2. Before you start: how we tested
  3. Check your Postgres version
  4. Declare the key as uuid with a uuidv7() default
  5. Insert without passing an id
  6. Measure the table and the index
  7. Read the creation time when you need it
  8. Switch an existing v4 table to v7
  9. What goes wrong
  10. What this means for you
  11. Related
  12. FAQ
  13. 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:

Terminal capture of psql on PostgreSQL 18.6: create table orders with id uuid primary key default uuidv7(), insert three rows returning ids 01a174f3-7ef1-7e0a..., 01a174f3-7ef2-7749... and 01a174f3-7ef2-7776..., then uuid_extract_timestamp showing 2026-10-25 17:24:37.233 and .234 Eastern time, version 7 for each. 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.

Terminal capture of psql: a 10 million row table keyed by text version 4 UUIDs is 1202 MB with a 728 MB primary key index; pgstatindex shows leaf density 69.7 and leaf fragmentation 49.9. 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.

Bar chart titled Random keys also write far more WAL. Gigabytes of write-ahead log for 10 million inserts: bigint identity 1.69, uuid with uuidv7 1.82, uuid v4 2.99, text v4 7.93. 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.

Troubleshooting

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.

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

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

Back to The Manual →   All articles →

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.

Up next

The article this answer comes fromPick a UUIDv7 library that keeps order: 10 tested, .NET fails 50%We tested 10 UUIDv7 libraries with 1M IDs each and a clock jump. .NET put 50% out of order, uuid6 ran 16.6 min ahead. The 2 that passed, plus free data.Read it next →

Get the receipts 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.

Cite this

Free to cite and reuse under CC BY 4.0 with a link back. How it was collected: methods. Spotted an error? Corrections.

Did this answer your question?

Popular in the shop

All 124 tools →

Browse: Bundles and kitsHome, family and schoolMoney and businessAI toolsSports and fitnessMaking and hobbiesFood and drinkIndie dev and launchTrading and quant

Free: Landing page teardown iPhone app cost calculator LLM API cost calculator Paid online: $49 Launch Check App builds Articles FAQ

What are people saying about this? Read and post on The Floor →