UUID v4 vs v7, and Why Time-Ordered IDs Make Better Database Keys
For years, the advice for primary keys was simple: use an auto-increment integer, or a random UUID if you need IDs that can be made anywhere. Random UUIDs have a cost that only shows on big tables, though. Version 7, standardized in RFC 9562 in 2024, fixes it by putting the time first. When I built ToolOnTap’s UUID generator, I made v7 one of its two modes and wrote both generators by hand, about 75 lines including an inspector that reads any UUID back.
128 bits, and the 6 that are fixed
A UUID is 16 bytes, written as 32 hex digits in groups of 8-4-4-4-12. Whatever the version, two fields sit in the same place:
- The version, the first hex digit of the third group. That’s the
4in…-4fff-…or the7in…-7cc3-…. - The variant, the top bits of the fourth group. For standard UUIDs they’re binary
10, so that group always starts with 8, 9, a or b.
Those take 6 bits. Everything else depends on the version.
Version 4: 122 random bits
Version 4 is the simplest possible design. Take 16 random bytes and overwrite the 6 fixed bits:
export function uuidV4(random: RandomBytes): Uint8Array {
const b = random(16)
b[6] = (b[6]! & 0x0F) | 0x40 // version 4
b[8] = (b[8]! & 0x3F) | 0x80 // RFC variant
return b
}
On the page, random is crypto.getRandomValues, the browser’s cryptographically secure generator. Math.random() would be the wrong choice here: it isn’t designed to be unpredictable, and UUIDs often end up in places where guessing one matters.
Browsers also have crypto.randomUUID(), which makes a v4 UUID in one call. I didn’t use it, because it only makes v4, and passing the random source in as a function lets the tests run the same code with fixed bytes. All-zero random bytes must give 00000000-0000-4000-8000-000000000000, and all-FF bytes must give ffffffff-ffff-4fff-bfff-ffffffffffff. Those two lines prove the bit masks are right.
With 122 random bits, duplicates aren’t a practical concern. You’d need to make a billion per second for about 86 years to reach a 50% chance of a single collision.
Why random keys hurt a database
Most relational databases keep a primary key in a B-tree index, a sorted structure split into pages. With an increasing key, every new row lands at the right-hand end of the index, in a page that’s already in memory. With a random key, each insert lands on an arbitrary page anywhere in the index. On a small table that’s invisible. On a table much larger than memory, inserts keep reading old pages from disk and splitting full ones, and the index ends up larger and half-empty in places. MySQL’s InnoDB stores the table rows themselves in primary key order, so there the whole table is affected, not only an index.
A v7 UUID keeps the advantage of UUIDs, IDs you can create in the app, on a phone or offline without asking the database, and behaves like an increasing key in the index.
Version 7: the timestamp goes first
RFC 9562 lays out a v7 UUID like this:
| Bits | Field | Content |
|---|---|---|
| 48 | unix_ts_ms |
Milliseconds since 1 January 1970, big-endian |
| 4 | ver |
7 |
| 12 | rand_a |
Random, or a counter |
| 2 | var |
Binary 10 |
| 62 | rand_b |
Random |
Because the time is the first field and is stored big-endian, sorting v7 UUIDs as bytes sorts them by creation time. Their hex text sorts the same way too, as long as you keep one letter case. 48 bits of milliseconds last until about the year 10889.
The RFC’s own example starts with 017f22e2-79b0, which is 1645557742000 ms, 22 February 2022 at 19:22:22 UTC. The tests feed that time to the generator and check that the UUID starts the same way.
Several IDs in one millisecond
The timestamp has millisecond resolution, and generating 1,000 UUIDs takes far less than a millisecond. With random bits after the timestamp, those 1,000 IDs would share a prefix and then sort in random order, which partly defeats the point.
RFC 9562 offers a few ways to handle this. I used its first method, a counter in the 12 bits of rand_a:
let ms = clock()
if (ms <= lastMs) {
ms = lastMs
counter++
if (counter > 0xFFF) { ms = ++lastMs; counter = random(1)[0]! & 0x7F } // counter full: borrow the next ms
}
else {
counter = random(1)[0]! & 0x7F // start low so there's room to count up
}
Three decisions are packed in there:
- The counter starts at a random value from 0 to 127, and not at the top of its range. That keeps a little randomness in the field and still leaves at least 3,968 steps before it overflows.
- When the counter fills up, the generator moves to the next millisecond rather than wrapping around, so order is never broken. In practice the 1,000-per-click limit on the page stays well under that.
- When the clock goes backwards, which happens when a computer syncs its time,
ms <= lastMsholds the last timestamp and keeps counting. A test feeds the clock 1000, 1000, 900, 1001 and checks that the four IDs still sort in order.
The generator is created once per page, so a second click continues from the first. Another test makes 5,000 IDs against a clock that advances unevenly and checks that sorting them changes nothing.
The trade-off is randomness. A v7 UUID from this generator has 62 fully random bits in rand_b, plus the random start of the counter, instead of v4’s 122. That’s still plenty to keep IDs made on different machines in the same millisecond apart, but v4 is the one to pick when an ID must be hard to guess.
What a v7 UUID gives away
The timestamp is readable by anyone. The “Check a UUID” box on the page proves it: paste a v7 UUID and it shows the moment it was made. If an ID appears in a public link, such as a shared document or an invite, a v7 UUID tells the world when that thing was created. Use v4 there.
The inspector reads more than v7. It accepts any common spelling (uppercase, no hyphens, braces, a urn:uuid: prefix) and reports:
- the version, with a short description for 1 to 8;
- the variant, including the old NCS and Microsoft ones;
- the nil UUID (all zeros) and the max UUID (all
f), which RFC 9562 added; - the creation time for v1 too. A v1 timestamp counts 100-nanosecond intervals since 15 October 1582, the start of the Gregorian calendar, spread over three fields in a scrambled order. The code puts the three pieces back together and subtracts 12,219,292,800,000 ms to reach the Unix epoch. The RFC’s v1 example,
C232AB00-9414-11EC-B3C8-9F6BDECED846, comes out as 2022-02-22 19:22:22 UTC, the same moment as its v7 example.
Storing them
Two practical notes that apply whichever version you choose. Store UUIDs in a native uuid column or as 16 bytes of binary, not as 36 characters of text: less than half the size, and faster comparisons. And if you use the “Uppercase” option for a system that wants it, don’t mix cases in one column, or text sorting stops matching time order.
Try it
The UUID generator makes up to 1,000 v4 or v7 UUIDs per click, with “Uppercase”, “Hyphens” and {braces} options for systems that want GUIDs in Microsoft’s style, and a “Download .txt” button when you make more than one. Paste one into “Check a UUID” to see what’s inside. More tools for developers are on the developer tools page.