UUID v1, v4 and v7: what leaks, what collides, what scales
A team uses UUID v4 as the primary key, ships, and is happy for a year. Then the table outgrows RAM. Insert latency grows a long tail and the write-ahead log balloons. The code is fine. The keys are doing what random keys always do to a B-tree.
Down the hall, another team puts v1 UUIDs in public URLs. They’re publishing the creation time of every record, plus a stable fingerprint of the machine that made it, and they don’t know it.
UUID versions aren’t interchangeable flavours of one thing. Each trades off uniqueness, secrecy and sort order differently, and RFC 9562 [1] (which replaced RFC 4122 [3]) spells out how. Which version you use is a privacy decision and a storage decision. Most people make it by accepting whatever the ORM defaults to.
Where the version lives
Every UUID is 128 bits, written as 32 hex digits in 8-4-4-4-12 groups. Two fields are the same in every version. The 4-bit version is the first hex digit of the third group. The 2-bit variant sits in the top bits of the fourth group: 10 for RFC-style UUIDs, which is why that digit is always 8, 9, a or b. That leaves 122 bits for content.
xxxxxxxx-xxxx-Vxxx-Nxxx-xxxxxxxxxxxx
^ ^
| variant: 8, 9, a or b
version: 1, 4, 7, ...
What goes in those 122 bits is where the versions differ.
| Version | Content of the 122 bits | Ordered by time | Leaks |
|---|---|---|---|
| v1 | 60-bit timestamp, 14-bit clock sequence, 48-bit node | Not lexically (low bits first) | Creation time, node identity |
| v4 | 122 random bits | No | Nothing by design |
| v7 | 48-bit Unix ms timestamp, version, then 74 bits of random or counter data | Yes | Creation time to the millisecond |
v1 tells on you
v1 counts 100-nanosecond ticks since 1582-10-15, the Gregorian calendar reform (lovely choice), in 60 bits. It adds a 14-bit clock sequence to survive the clock going backwards, and a 48-bit node ID, which traditionally is the MAC address of the machine that made it.
So anyone holding a v1 UUID can work out when it was created, and, if the node is a real MAC, on which machine. RFC 9562 explicitly allows a random node with the multicast bit set instead, and most modern libraries do that. Check yours.
The other problem: the timestamp is stored in a scrambled order. time_low comes first, then time_mid, then time_hi, so the fastest-changing bits lead and v1 values don’t sort by creation time, neither as strings nor as bytes. You pay the privacy cost of a timestamp and get none of the indexing benefit.
Decoding takes a few lines:
import uuid, datetime
u = uuid.UUID("c232ab00-9414-11ec-b3c8-9f6bdeced846")
# 100 ns ticks since 1582-10-15; offset to the Unix epoch
ticks = u.time - 0x01b21dd213814000
print(datetime.datetime.fromtimestamp(ticks / 1e7, datetime.timezone.utc))
print(hex(u.node)) # multicast bit set => random node, not a real MACIf a v1 UUID has ever been in a URL, an API response or a log you shared, the creation time is public. Look at the lowest bit of the first node octet: if it’s set, the node ID is random and not a real MAC.
v4 is just random
122 bits from a cryptographically secure random generator, nothing else. No structure, so nothing to leak.
It’s only as good as its source, though. A non-cryptographic PRNG (Math.random() in older runtimes, a seeded rand()) makes the values predictable, and some very old libraries did exactly that. Use the OS CSPRNG.
And unguessable isn’t the same as safe to use for authorisation. RFC 9562 itself warns against treating UUIDs as security capabilities. For that you want a purpose-built token with an explicit expiry.
Will two ever collide?
For n random values out of N possibilities, with n much smaller than N, the chance of at least one duplicate [2] is about
p ≈ n² / (2N)
For v4, N = 2^122 ≈ 5.3 × 10^36. A trillion rows, n = 10^12:
p ≈ (10^12)² / (2 × 5.3×10^36) = 10^24 / 1.06×10^37 ≈ 9.4 × 10^-14
Roughly one in ten trillion. Getting to a 50% chance takes about n ≈ 1.18 × √N, around 2.7 × 10^18 UUIDs. That’s a billion a second for about 85 years. Your storage controller flipping bits is the likelier problem. And your unique constraint is the backstop anyway: a collision becomes a failed insert you retry, not silent corruption.
v7 is fine too, though the maths is a bit different. Its collision domain isn’t 2^122 but the random bits within one timestamp tick, 74 of them (RFC 9562 lets part of that be a monotonic counter). Two v7 IDs can only collide if they’re made in the same millisecond. A million IDs in one millisecond across the whole system gives p ≈ (10^6)² / (2 × 2^74) ≈ 10^12 / 3.8×10^22 ≈ 2.6 × 10^-11 for that millisecond. Rare, and the unique index catches it.


What random keys do to a B-tree
This is what actually hurts in production. A primary-key index is usually a B+tree: sorted pages, each holding a range of keys. Every insert has to find the leaf page its key belongs on.
With auto-increment or time-ordered keys, each new key is bigger than all the others, so every insert hits the rightmost leaf. That page stays hot in cache, pages fill up completely before they split, and writes are sequential.
With v4, each insert lands on a random leaf. Once the index is bigger than the buffer pool, most inserts have to read a page from disk first. Random inserts into full pages split them, typically leaving two halves about 50 to 70 percent full, so the index takes more space on disk and in memory. Each batch of transactions dirties many separate pages instead of one hot page, which means more WAL and more flushing.
Clustered engines get it worst. In InnoDB the primary key is the table, so random keys scatter the rows themselves, and every secondary index carries the wide key as well. PostgreSQL heap tables are unordered, so it’s milder there, but the primary key index loses locality just the same.
I won’t quote a speedup number. It depends on row width, buffer pool size and workload. Benchmark your own schema before promising anyone a figure.


v7 puts the clock first
v7 puts a big-endian 48-bit Unix millisecond timestamp in the most significant bits, then the version nibble, then 74 bits of randomness. An implementation can use some of those as a counter so one process’s IDs stay monotonic within a millisecond. Since the timestamp comes first and the encoding is big-endian, plain byte or string comparison sorts v7 UUIDs by creation time, to the millisecond. Inserts become nearly append-only, and you still get globally unique IDs without a coordination service.
The downsides are real, though. Anyone with the ID knows when the record was created, to the millisecond. If a record’s age is sensitive (an account’s creation date, when a draft was started), v7 in a public identifier leaks something v4 doesn’t. The usual answer is v7 as the internal primary key and a separate random ID for public use.
Ordering only holds per millisecond. Within one, it depends on the counter or the randomness, and clocks on different machines drift, so global order is approximate. An NTP step backwards can produce IDs smaller than ones already handed out; good libraries clamp to the last timestamp they saw. And the timestamp part is guessable, so only the 74 random bits resist enumeration. Fine for keys, wrong for secrets.
Generate a few v7 IDs in the UUID generator and sort them as plain strings: they stay in creation order. Try the same with v4 and there’s no order to keep.
Which one
| Situation | Pick |
|---|---|
| Database primary key, high insert rate, table larger than memory | v7 |
| Identifier exposed publicly where creation time must stay private | v4 (or v7 internally plus a random public ID) |
| Existing v1 data | Keep it, but treat the timestamp as public and the node as a fingerprint |
| Anything used as a secret or bearer token | Neither: use a dedicated random token with expiry |
Whichever you pick, store UUIDs as 16-byte binary where your database supports it (a native uuid type, or BINARY(16)), not as 36-character strings. That more than halves the key, and the saving repeats in every secondary index.
Try it yourself
UUID and ULID generator
Generate UUID v4, UUID v7 and ULID in bulk, or paste an ID to see its version, variant and timestamp.
Open the tool