UUID v4 vs v7, and what's inside a UUID
By Flavio Copes
Learn what the bits inside a UUID mean, why random v4 IDs hurt database index performance, how v7 embeds a timestamp, and when to pick each.
A UUID looks like random noise:
018f6d2e-9b3a-7cc0-8f4e-2a91b3d0c771
It’s not. A few of those characters are structured, and reading them tells you what kind of UUID you’re looking at, and sometimes even when it was created.
Let’s open one up.
The anatomy of a UUID
A UUID is 128 bits, written as 32 hex characters in a 8-4-4-4-12 pattern:
xxxxxxxx-xxxx-Vxxx-Nxxx-xxxxxxxxxxxx
Two positions are special.
The V position (the first character of the third group) is the version. It tells you how the UUID was generated. A 4 there means random, a 7 means time-based.
The N position (the first character of the fourth group) carries the variant bits. For all the UUIDs you’ll encounter in practice, this is 8, 9, a or b, which means “standard RFC layout”. If you see anything else there, it’s not a standard UUID.
So in the example above, the 7 in the third group tells us it’s a UUID v7, and the 8 in the fourth group confirms the standard variant.
That’s 6 bits used for metadata. The other 122 bits are where the versions differ.
UUID v4: pure randomness
In a v4 UUID, all 122 remaining bits are random. That’s it. No timestamp, no machine ID, nothing to decode.
The browser and Node.js can generate one natively, as I showed in generating UUIDs with crypto.randomUUID():
crypto.randomUUID()
//'b7bcf843-a49a-4f0f-9184-25fa38e4a712'
122 random bits means collisions are a theoretical concern only. You’d need to generate billions of IDs per second for decades to have a realistic chance of two matching.
And because it’s completely opaque, a v4 UUID leaks nothing. Nobody can tell when it was created or which two IDs were created close together. That’s a feature for anything user-facing.
Why v4 hurts your database
Here’s the problem. Databases store primary keys in B-tree indexes, and B-trees love ordered inserts.
With an auto-increment integer key, like the ones I described in SQL unique and primary keys, every new row gets a key bigger than the last. New entries always land on the rightmost page of the index. That page sits hot in memory, fills up, and the tree grows neatly.
With v4 UUIDs, every insert lands at a random position in the index. The database keeps touching cold pages all over the tree, splitting them, writing them back. On a large table this means more I/O, a bloated fragmented index, and a cache that keeps getting evicted.
You won’t notice with 10,000 rows. With 100 million rows and a heavy insert load, you will.
UUID v7: a timestamp in the high bits
UUID v7 (standardized in RFC 9562) fixes exactly this. The first 48 bits are the Unix timestamp in milliseconds. The rest is random, minus the version and variant bits, so about 74 bits of randomness.
018f6d2e-9b3a-7cc0-8f4e-2a91b3d0c771
└──────────┘
48-bit Unix ms timestamp
Because the timestamp sits in the most significant bits, v7 UUIDs generated later sort after ones generated earlier. As plain strings, no parsing needed.
For a database that means inserts land on the right edge of the index, just like auto-increment integers. You keep the benefits of UUIDs — globally unique, generated anywhere without coordinating with the database — and drop the random-insert penalty.
The trade-off: the timestamp is readable. Anyone holding one of your v7 IDs can extract when the record was created:
const uuid = '018f6d2e-9b3a-7cc0-8f4e-2a91b3d0c771'
const hex = uuid.replaceAll('-', '').slice(0, 12)
new Date(parseInt(hex, 16))
//2024-05-12T09:32:29.882Z
The first 12 hex characters are the timestamp. Convert to a number, pass to Date, done.
For most apps that’s harmless. If creation times are sensitive — think medical records — stick with v4.
What about ULIDs?
ULIDs solve the same problem as v7, and came first. Same 128 bits, same idea: 48-bit millisecond timestamp up front, 80 random bits after.
The difference is the encoding. A ULID is 26 characters of Crockford base32:
01HZAM9W5DPBQK4V2R8T6XY3FJ
No dashes, case-insensitive, and no characters that look alike (no I, L, O, U). Nicer in URLs.
Since RFC 9562, my advice is to prefer UUID v7 for new projects. It does the same job, and UUIDs have native column types (uuid in Postgres), native generation functions, and universal library support. ULIDs are fine, they’re just one more dependency and one more format your team has to know.
When to use which
- v4 — API keys, tokens, session IDs, anything public where opaqueness matters. Or any table without heavy insert volume.
- v7 — primary keys on tables with lots of inserts, IDs you want to sort by creation time, distributed systems generating IDs on many nodes.
- auto-increment integers — still perfectly good for single-database apps, as long as you’re comfortable exposing sequential IDs (or you don’t expose them at all).
You’ll also run into v1 UUIDs in older systems. They’re time-based too, but they embed the network card’s MAC address, which is a privacy leak. v7 replaced them for good reason.
Inspect one yourself
The fastest way to make all of this stick is to look at real IDs. Paste any UUID or ULID into my UUID inspector and it decodes the version, the variant bits, and the embedded timestamp if there is one. Generate a v4 and a v7 side by side and the difference is immediately obvious.
Related posts about js: