UUID v4 vs v7: which one to use, and what v7 gives away
A table with a random UUID primary key is fast at 10,000 rows and noticeably slower once the index no longer fits in memory. That is the usual reason people start asking about UUID v7, and the answer is mostly about where new rows land in the index.
Why random keys hurt a B-tree
A v4 UUID has 122 random bits, so a new key can sort anywhere in the index. Each insert touches an arbitrary leaf page, which must be in memory or read from disk, and a full page splits to make room. Sequential keys behave differently: every insert goes to the right edge of the index, the same few pages stay hot, and pages fill completely before the next one starts.
A v7 UUID starts with a millisecond timestamp, so ids created later sort after ids created earlier. Inserts cluster at the right edge the way an auto-increment column does, while the id can still be generated by any client without asking the database. If your table is small, or the UUID is never an index you range over, the difference will not matter and v4 is fine.
What is inside a v7
RFC 9562 (May 2024, which supersedes RFC 4122) defines the layout as 128 bits:
| Bits | Field | Value |
|---|---|---|
| 48 | unix_ts_ms | milliseconds since the Unix epoch, big-endian |
| 4 | ver | 0111, the digit 7 |
| 12 | rand_a | random (or a counter) |
| 2 | var | 10 |
| 62 | rand_b | random |
That leaves 74 random bits, against 122 in v4. In text form the version digit is the first character of
the third group and the variant is the first character of the fourth: xxxxxxxx-xxxx-7xxx-yxxx-xxxxxxxxxxxx,
where y is 8, 9, a or b. Because the timestamp is the leading 12 hex digits, you can read it back:
const ms = parseInt(id.replace(/-/g, '').slice(0, 12), 16);
new Date(ms).toISOString(); // when this id was created
Forty-eight bits of milliseconds last until the year 10889, so overflow is not a concern.
Ordering inside one millisecond
Two ids created in the same millisecond share a timestamp, and the next 74 bits decide their order. If they
are freshly random, the second id can sort before the first. RFC 9562 describes ways to avoid this, such
as using part of rand_a as a counter, or incrementing the random field when the clock has not moved.
The UUID generator linked below takes the second approach: ids made in the same millisecond keep
incrementing the random field, so a list of 1000 comes out strictly ascending. It also never lets its
timestamp go backwards if the system clock does.
Two limits remain whatever the generator does. Ordering across several machines is only as good as their clocks, so v7 gives roughly-by-time, not a total order of events. And a sorted text column only sorts correctly if every value is spelled the same way: mixing uppercase and lowercase hex, or hyphenated and unhyphenated forms, breaks a plain string comparison.
What a v7 gives away
The first 48 bits are a creation time anyone can decode, as the snippet above shows. For a database key that is mostly harmless, but it matters in a few places:
- A public URL such as
/orders/<uuid>tells a visitor when that order was placed, and the spread of timestamps across ids reveals your order volume. - An id used as an unguessable token or reset link should come from a purpose-built random source with at least 128 random bits, not from a UUID of either version. v4 from a CSPRNG is hard to guess, but nothing about the format promises that.
- v1, the old time-based version, is worse: it stores a 60-bit timestamp, a clock sequence and a node id that was often the machine’s MAC address. Its timestamp is also stored low bits first, so v1 ids do not sort by time as text.
Storage matters more than the version
A UUID is 16 bytes, yet it is commonly stored as a 36-character string, which is 36 bytes before the
overhead. Use the native type where there is one: PostgreSQL has uuid, and MySQL’s usual answer is
BINARY(16). Every secondary index copies the primary key, so a fat key is paid for several times.
Storing v7 as text still sorts correctly, but it costs more space; and a binary column sorts bytewise,
which is exactly timestamp order for v7.
Choosing
| Situation | Use |
|---|---|
| Primary key in a table that will grow large | v7 |
| Identifier that must not reveal when it was made | v4 |
| Test fixtures, one-off ids, small tables | either; v4 is the default almost everywhere |
| Placeholder or “no value” marker | NIL, 00000000-0000-0000-0000-000000000000 |
| Unguessable secret | neither; use a dedicated random token |
Two other versions appear in RFC 9562 and are rarely the right pick. v6 reorders v1’s timestamp so it sorts, which only makes sense for systems already on v1, and v8 is left for custom layouts. The same RFC also adds a Max UUID of all ones as the counterpart to NIL.
Do random UUIDs collide?
With 122 random bits, the birthday bound puts a 50% chance of a duplicate at roughly 2^61 ids, about
2.3 x 10^18. A real table never gets near that. Duplicates that do occur in practice come from a bad
source: a hand-rolled “UUID function” built on Math.random(), or a cloned virtual machine image that
restarts a generator from the same seed. crypto.randomUUID() or your platform’s UUID library avoids both.
For v7 only ids from the same millisecond can collide, and within one process the counter approach above
rules that out.
Generating them
In a browser, crypto.randomUUID() returns a v4 and needs a secure context (https or localhost). There is
no built-in v7 in the browser, which is the gap the generator tool fills: it offers v4, v7 and NIL, 1 to
1000 at a time, with toggles for uppercase, hyphens and the urn:uuid: form. Entropy comes from
crypto.getRandomValues, and generation happens in the page, so no ids are sent anywhere.