Esc
writing

How UUIDv7 fixes the primary-key tradeoff

Auto-increment is cheap until you have two writers, and random UUIDs wreck your index. UUIDv7 puts the clock first and stops making you pick.

Isometric illustration of a B-tree: a root page linked to four leaf pages filled in order, with the newest cube dropping into the last free slot.
filed
read
5 min
words
900
revision
r1
tags
databases · postgres · uuid
note no.
02 / 04by s.s.

Every new table asks the same question: how do you identify a row? Most of us answer on autopilot. An auto-increment integer, because it’s right there. Or a UUID, because someone once said integers don’t scale. Both send you a bill later. UUIDv7 is the first option I’ve found that doesn’t make me pick.

Auto-increment is great, until there are two of you

Every engine ships one, whether it’s AUTO_INCREMENT in MySQL, GENERATED ... AS IDENTITY in Postgres or IDENTITY in SQL Server, and it’s tiny: four bytes for an int, eight for a bigint, perfectly fine in a URL like orders/1042. Your index loves it.

The catch? The next number has to come from one place, so the moment two servers are inserting at once they either coordinate or they both hand out 1043.

It’s also guessable. If you’re /users/42, you can probably guess where /users/43 lives, and if the app forgets to check, you’re in. That’s an IDOR (insecure direct object reference), and it’s exactly as embarrassing as it sounds.

UUIDv4 fixes both and quietly wrecks your index

A v4 is 122 random bits with the version and variant stamped on top. Any process can mint one without asking anyone, and nobody’s walking your table by adding one.

The bill arrives in the index. A primary key lives in a B-tree, which keeps entries sorted across fixed-size pages. Sequential keys always land at the right edge: fill a page, start the next one, very tidy. A v4 lands wherever it sorts, usually in a page that’s already full, so the database splits it. Every time. New page, copy half the rows over, update the parent. That costs CPU and I/O, fragments the index, and makes reads touch more pages. And at 16 bytes, a UUID is four times an int, so less of the index fits in memory.

Demo 1: watch the index fill up

Pick a key style, press "Insert 20", then switch style and compare pages, splits and how full the pages are.

0keys
0pages
0mid-page splits
0%average fullness

Simulation with 4 keys per page so you can see it. Real pages hold hundreds of keys, and real engines are more subtle. The pattern, though, is the same. A grey page is a new page added at the right edge. A thick white outline is a full page that had to split in the middle. Random keys here are numbers from 0 to 999.

What v7 changes

v7 keeps the 128-bit shape and just reorders it. The first 48 bits are a Unix timestamp in milliseconds, then the version and variant bits, then randomness. That’s RFC 9562, the 2024 standard that replaced RFC 4122.

A 128-bit bar drawn to scale: 48 bits unix_ts_ms (the timestamp, which makes v7 sort by time), 4 bits version, 12 bits rand_a, 2 bits variant, 62 bits rand_b. Bit 0 is at the timestamp end, bit 127 at the far end of rand_b.
Field widths are drawn to scale. Layout from RFC 9562, section 5.7.

Clock in front means a later v7 sorts after an earlier one, as plain bytes. (Version 1 had a timestamp too, but stored it in an order that doesn’t sort by time. Classic.) So v7 appends to the right edge of the tree like an integer, while the random tail keeps it unique and lets any node make one on its own.

Demo 2: take a v7 apart

Press "Generate a v7", then edit the first few characters of the ID and watch the date change. Paste any UUID to see its version.

Version
Timestamp
Age
Random tail

Generated in your browser with crypto.getRandomValues and your clock. The white part is the millisecond timestamp and it's readable by anyone who holds the ID. More on that below.

Demo 3: do they sort?

Press the button. Six IDs of each kind are made a few milliseconds apart, then checked to see whether plain text order matches creation order.

v4 (random), in creation order

    v7, in creation order

      Six v4 values could land in order by luck, but the odds are 1 in 720. The v7 side waits a few milliseconds between IDs. Made within the same millisecond, ordering depends on how the generator handles ties, which is what RFC 9562 section 6.2 is about.

      What the numbers look like

      I haven’t benchmarked this myself, so here are two runs from other people, both single blog posts rather than lab results, which disagree on how big the gap is but not on which way it points. The smaller one (about 44,000 rows, Postgres 15) saw v7 inserts take about half the time of v4, with a table roughly a fifth smaller. Test on your own data before you promise anyone a speedup.

      Benchmark A: 5 million rows, Postgres

      Figures from a third-party blog post (linked in the sources), not from my machine. Heights are scaled within each group.

      Index size: bigint 107 MB, v4 285 MB, v7 118 MB. Average insert time: bigint 0.8 ms, v4 3.1 ms, v7 0.9 ms. v4 is the tallest in both groups; v7 is close to bigint.

      The author notes the result assumes a steady clock and doesn't cover clock skew across several nodes.

      Demo 4: what does 16 bytes cost you?

      Drag the slider to change the row count and compare the raw key bytes for each type.

      int (4 B)
      bigint (8 B)
      uuid (16 B)

      Simple arithmetic: rows times key size, one copy of the key. A real index adds per-entry overhead and free space, so actual sizes are larger. An int tops out near 2.1 billion values.

      What v7 can’t do

      It’s still 16 bytes. Try reading one out over the phone.

      The timestamp is out in the open, too. Anyone holding a v7 can tell roughly when the row was created and how fast you’re issuing IDs. Demo 2 does it in a few lines, and Postgres ships uuid_extract_timestamp() for exactly this.

      It also has fewer random bits than v4 (74 against 122). That’s plenty for avoiding collisions, but the RFC says UUIDs must not be used as security capabilities, and I’d take that personally. Reset links, session tokens, invite codes: use a v4 or a proper random token. Keep v7 for primary keys and log IDs.

      Last one: with several writers, order is only as good as their clocks, and servers a few milliseconds apart will give you IDs that are roughly sorted rather than strictly sorted. Your index won’t care. If you need strict global order, v7 alone won’t get you there.

      What I’d reach for

      One database and IDs that never leave it? Auto-increment is still the leanest thing going. Don’t pay 8 extra bytes for problems you don’t have.

      More than one writer, or an ID that shows up in a URL or an API response? Skip v4 and use v7. You keep decentralised generation and unguessable IDs without making your index do busywork.

      Demo 5: which key should this table use?

      Tick the boxes that match your table and read the suggestion. It's my rule of thumb from this post, not a law.

      Adopting it is nearly a one-liner now. Postgres 18 (September 2025) ships a built-in uuidv7():

      CREATE TABLE orders (
        id uuid PRIMARY KEY DEFAULT uuidv7(),
        placed_at timestamptz NOT NULL DEFAULT now()
      );
      
      SELECT uuid_extract_timestamp(id) FROM orders LIMIT 1;

      On older Postgres, generate it in your app. MySQL’s UUID() makes version 1, so generate v7 client-side and store it as BINARY(16). Hibernate 7 has a VERSION_7 generator style and .NET 9 has Guid.CreateVersion7().

      Using v7 as a primary key? Tell me what you’re on and whether the index difference showed up for you. I’d genuinely like to know.

      Sources

      1. RFC 9562, Universally Unique IDentifiers (UUIDs), May 2024: rfc-editor.org/rfc/rfc9562.html
      2. PostgreSQL 18 documentation, UUID functions: postgresql.org/docs/18/functions-uuid.html
      3. PostgreSQL 18 release notes: postgresql.org/docs/current/release-18.html
      4. Benchmark A (5M rows, Postgres), third-party blog post: dev.to/iurii_rogulia/uuid-v7-vs-v4-postgresql-performance-benchmark-1d3k
      5. Benchmark B (about 44k rows, Postgres 15), third-party blog post: mblum.me/posts/pg-uuidv7-benchmark
      6. Hibernate 7 UuidGenerator.Style: docs.hibernate.org
      7. .NET Guid.CreateVersion7: learn.microsoft.com
      8. MySQL and UUIDs (Oracle MySQL blog): blogs.oracle.com/mysql/mysql-uuids

      All demo values are generated live in your browser or are simple arithmetic. Benchmark figures are from the linked posts, not my own measurements.