Skip to content
100% in your browser. Nothing you paste is uploaded — all processing runs locally. Read more →

UUID v4 vs v7 — which to use for database primary keys

On this page
  1. What actually differs in the bits (random vs time-ordered)
  2. Why B-tree indexes hate random inserts
  3. When v7 is the better default (new tables, write-heavy PKs)
  4. When to keep v4 (public/opaque IDs, creation-time privacy)
  5. UUID v7 vs ULID
  6. How to generate either in the browser

You’ve already picked a UUID as the primary key. The remaining choice is v4 vs v7 — and it is not about uniqueness.

Both are 128-bit identifiers. Both are collision-safe for any workload you will actually run. What differs is where each new key lands in a B-tree. Random v4 keys scatter inserts across the index. Time-ordered v7 keys — standardized in RFC 9562 (May 2024) — append near the right edge, the way a bigint identity does. Collision math is a distraction. Timestamp leakage vs index locality is the real trade-off.

This page is the v4-vs-v7 primary-key comparison. For UUID vs auto-increment, see UUID as primary key. To mint a v7 in the browser, use the UUID v7 generator.

What actually differs in the bits (random vs time-ordered)

Same size. Different prefix.

UUID v4 fills about 122 bits from a CSPRNG and stamps the version/variant nibbles. There is no clock and no sequence. Two IDs generated a millisecond apart are unrelated. Lexicographic sort is random.

UUID v7 puts a 48-bit Unix millisecond timestamp in the most-significant bits, then version 7, then about 74 random bits (including the variant). New IDs share a time prefix. Lexicographic sort matches chronological sort. Same-millisecond uniqueness comes from the random tail — some libraries add a monotonic counter.

v4:  [ 122 random bits | version | variant ]
v7:  [ 48-bit Unix ms | ver | rand | var | rand ]

That 48-bit prefix is why a time-ordered UUID in PostgreSQL inserts like a sequence. It is also why anyone who sees a v7 can recover the creation millisecond. You cannot have sequential index locality without encoding something ordered — usually time.

Storage is the same 16 bytes if you use a native type:

-- PostgreSQL
id uuid PRIMARY KEY

-- MySQL / InnoDB
id BINARY(16) PRIMARY KEY

-- SQL Server
id uniqueidentifier PRIMARY KEY

Do not store either version as VARCHAR(36) or CHAR(36). You pay roughly 2× the bytes, lose type checks, and widen every index. The hyphenated string is a display format, not a column type.

Collision risk is not the deciding factor. v4’s 122 random bits and v7’s 74 random bits per millisecond are both far past the birthday-paradox threshold of any real table. Choosing v4 “to be safer” optimizes the wrong variable. The collision math will not pick your primary key.

Why B-tree indexes hate random inserts

Postgres, InnoDB, and SQL Server all keep a primary-key index in a B-tree (or B+tree). Inserts walk the tree to the leaf that owns that key.

Sequential keys — identity integers, and v7 values generated around “now” — always land on the rightmost leaf. That page stays in cache. A full leaf splits once and the new right page becomes the hotspot. WAL / redo stays compact. For a primary key, that insert hotspot is a good hotspot.

Random v4 keys land on a random leaf. On a large table that leaf is usually cold. The engine:

  1. Reads a random index page
  2. Inserts into the middle of a packed leaf
  3. Splits the page when it overflows
  4. Dirties two pages instead of one
  5. Writes extra WAL
  6. Leaves both pages half-full — index bloat

That is write amplification: one logical row insert becomes scattered reads, extra page splits, and more dirty pages flushed. The cache working set becomes “the whole index” instead of “the right edge.”

On a clustered index (InnoDB, SQL Server’s default) the table itself is stored in primary-key order. Random PK inserts physically scatter rows. Secondary indexes still point at the PK, so the pain shows up on every write.

Typical shape on a large PostgreSQL table — order of magnitude, not a lab claim:

Primary keyInsert patternRelative insert rateIndex bloat
bigint identityrightmost leaf100% (baseline)low
UUID v7rightmost leaf~90%low
UUID v4random leaf~30–50%high

The old advice — “don’t use UUIDs as primary keys” — was about this v4 pattern. It was right. It does not apply to v7.

PostgreSQL BRIN indexes only help if physical order matches the key — v7 can use them, v4 cannot. For uuidv7() vs gen_random_uuid(), see the PostgreSQL UUID guide.

When v7 is the better default (new tables, write-heavy PKs)

Use v7 when:

You do not need to rewrite existing v4 rows. Change the default — or the app generator — and let new keys be v7. Mixed versions in one uuid column are valid; the version nibble is in the value. Skip v6 unless you are migrating off v1 — RFC 9562 includes both; v7 is the modern layout. See UUID versions.

When to keep v4 (public/opaque IDs, creation-time privacy)

Keep v4 when opacity matters more than insert locality.

A common split: v7 as the PK, plus a separate opaque public ID if the URL must not leak time. Two columns, two indexes — only when both constraints are real.

UUID v7 vs ULID

ULID solves the same time-ordered problem: a 48-bit millisecond prefix plus random bits, encoded as Crockford base32 (26 characters, no hyphens). Fine for string-native stores.

For a SQL primary key, prefer UUID v7. You get a native type, ORMs already know UUIDs, and switching from v4 is a generator change, not a column-type change. ULID’s extra random bits do not change the collision story or B-tree behavior — both designs share the time-prefix insert pattern.

How to generate either in the browser

Generate both versions on this site. The work stays in your browser — nothing is uploaded, there is no account, and the ID never hits our servers.

Choosing a primary key today: generate a v7, store it as a native UUID, and keep v4 where the clock in the ID would be a problem.