UUID v4 vs v7, and what's inside a UUID

By

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 can look like random noise:

018f6d2e-9b3a-7cc0-8f4e-2a91b3d0c771

Some characters have a defined meaning. They tell you which UUID version you have and, for some versions, when it was created.

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. There is no timestamp or machine ID 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, puts time at the start of the ID. The first 48 bits are the Unix timestamp in milliseconds. The rest is random, minus the version and variant bits, leaving about 74 bits of randomness.

018f6d2e-9b3a-7cc0-8f4e-2a91b3d0c771
└──────────┘
 48-bit Unix ms timestamp

Because the timestamp sits in the most significant bits, later v7 UUIDs sort after earlier ones. This works with plain strings and needs no parsing.

For a database, this means inserts land on the right edge of the index, like auto-increment integers. The IDs remain globally unique and can be generated anywhere without coordinating with the database. You also avoid 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, as with medical records, stick with v4.

What about ULIDs?

ULIDs came before v7 and solve the same problem. They use 128 bits: a 48-bit millisecond timestamp followed by 80 random bits.

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, while UUIDs have native column types (uuid in Postgres), native generation functions, and universal library support. ULIDs are fine, but they add another dependency and format for your team to know.

When to use which

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

Paste any UUID or ULID into my UUID inspector. It decodes the version, variant bits, and embedded timestamp when the ID has one. You can also generate a v4 and v7 side by side to compare them.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about js: