Toolora

UUID v4 vs v7: which should you use?

Why v7 indexes so much better than v4, when random is still the right answer, and the creation time v7 hands out for free.

A UUID is 128 bits, written as 32 hexadecimal digits in five hyphen-separated groups — 36 characters on the page. The point of it is that two machines which have never spoken to each other can each mint an identifier and be confident the two will never collide.

Four of those bits record the version and two more record the variant, which leaves 122 bits for the version’s own scheme to fill. v4 fills all 122 with randomness. v7 spends the first 48 on a timestamp. Everything below follows from that one decision.

v4: 122 random bits

There is no structure in a v4 UUID. No timestamp, no counter, no ordering — just noise from a cryptographically secure random source.

The collision question comes up constantly and deserves a real number rather than a shrug. With 122 random bits, you would need to generate roughly 2.7 × 10¹⁸ UUIDs before there was an even chance that any two of them matched. At a billion per second, that is about 85 years of continuous generation.

Nothing you build will come close. Treat v4 collisions as a non-issue and spend your attention elsewhere.

v7: timestamp first, randomness after

RFC 9562 lays v7 out like this:

48 bits  Unix timestamp, milliseconds
 4 bits  version (7)
12 bits  rand_a — often a sub-millisecond counter
 2 bits  variant
62 bits  rand_b

Because the timestamp sits in the most significant bits, and because hex digits sort in the same order as the numbers they stand for, sorting v7 UUIDs as plain strings sorts them chronologically. No parsing, no separate created_at column to order by.

The 48-bit millisecond field does not overflow until around the year 10889, so that is not a concern you need to plan around.

One detail worth knowing: two v7 UUIDs generated inside the same millisecond have no guaranteed order between them unless the generator implements the monotonic counter method, which uses rand_a as a within-millisecond counter. Generators that skip it will hand you a batch that is only roughly ordered — which quietly defeats the point if you generate in bulk. Toolora’s generator implements the counter, so a batch of 500 created in a single millisecond still sorts correctly.

The real difference: what happens in the index

This is why both versions exist, and it is the only part of this comparison with a measurable cost attached.

A B-tree index keeps its keys in sorted order across fixed-size pages. Where a new key lands is decided entirely by its value.

With v4, every insert goes somewhere random. The leaf page that new key belongs to is usually not in the buffer pool, so a write turns into a read followed by a write. Pages fill unevenly, and when a full page receives another row it splits in two, leaving both halves half-empty. The index ends up larger than the data justifies, and a larger index caches worse — which makes the next random insert more likely to miss, and so on.

With v7, inserts arrive in ascending order and land on the rightmost leaf. That page is already in memory because you used it a moment ago. Pages fill completely before the next one opens, so the fill factor stays high and fragmentation stays low. The working set for inserting is a handful of pages instead of the whole index.

The effect is largest where the primary key is clustered — InnoDB in MySQL, and clustered indexes in SQL Server — because there the table rows themselves are physically stored in key order, so random keys scatter the row data as well as the index entries. PostgreSQL stores rows in a heap, so the table is not reordered, but the primary-key index is still a B-tree and still benefits from ordered inserts.

One honest caveat: sequential keys concentrate every insert on the same rightmost page, which under very heavy concurrent write load can become a contention point. Random keys spread that load across the index. For most applications v7 is the better trade; at extreme write concurrency it is worth measuring rather than assuming.

When v4 is still the right answer

Reach for v4 when the identifier is going to be visible and you do not want it to say anything.

  • Public-facing identifiers where creation time is sensitive — invitation links, share URLs, anything a stranger might hold.
  • Anywhere ordering is itself information. If IDs appear in a public list, v7 timestamps expose your activity pattern over time: when you are busy, when you are quiet, when you launched.
  • When you simply do not need ordering. If the identifier is a lookup key and nothing sorts by it, v4’s lack of structure costs you nothing.

What v7 gives away

Anyone holding a v7 UUID can read the millisecond it was created. That is not a flaw — it is the whole design — but it is a property you are opting into.

For an internal primary key, nobody cares. For an order number in a URL, a customer learns when their own order was placed, which is harmless. For a user ID exposed in an API, it tells anyone who can enumerate a few of them roughly when each account was created.

Decide deliberately, and remember that neither version is a secret. 62 random bits make a v7 unguessable in practice, but a UUID in a URL is an identifier, not an authorisation. If knowing the ID is what grants access, you have an access-control bug. And if you are reaching for a hash to make an identifier feel unguessable instead, that is usually the wrong tool for the job.

UUID or an auto-incrementing integer?

Integers are smaller and easier to read aloud. UUIDs buy you three things worth paying for:

  • Generate anywhere. A client, a worker, or an offline device can mint an ID without a round trip to the database.
  • Merge without renumbering. Two datasets created independently can be combined as-is.
  • No row counts leaked. Sequential IDs tell an outsider how many records you have and how fast that number grows.

The cost is size: 16 bytes against 4 or 8, plus reduced readability.

Store them as 16 bytes

This is the most common and most expensive mistake people make with UUIDs.

A UUID is 128 bits — 16 bytes. Written out with hyphens it is 36 characters. Storing it in a text column more than doubles the size, and in MySQL a CHAR(36) under utf8mb4 reserves four bytes per character.

In InnoDB the primary key is copied into every secondary index, so that difference multiplies with each index on the table.

Use the native type where one exists: uuid in PostgreSQL, BINARY(16) in MySQL, uniqueidentifier in SQL Server.

Side by side

v4 v7
Structure 122 random bits 48-bit timestamp + 74 bits
Sorts chronologically No Yes
Index insert pattern Random Sequential
Reveals creation time No Yes
Best default for public-facing identifiers database primary keys

The short version: use v7 for database keys, v4 for identifiers strangers will see. If you genuinely cannot decide, v7 is the better default for new work — the index behaviour is a real, recurring cost, and the leaked timestamp usually is not.

Generate either version — v4 or v7, in bulk, entirely in your browser.