
What RFC 9562 changed
For almost twenty years, “UUID” meant RFC 4122 from 2005, and in practice it meant version 4: generate 122 random bits, format them as 36 hex characters, done. That changed in May 2024, when RFC 9562 obsoleted RFC 4122 and standardised three new versions, of which v7 is the one everyone was waiting for.
The idea behind v7 is old and boring, which is exactly why it works: put the time first. A v7 starts with a 48-bit Unix timestamp in milliseconds, then fills the rest with randomness. Two v7 values generated a second apart will sort in generation order, whether you compare them as strings, as bytes, or as a uuid column in a database. A v4 gives you none of that. It’s a coin flip repeated 122 times. You can watch the difference live in our UUID generator, which highlights the timestamp bits and decodes them under every v7.
And that single property, keys that arrive in roughly ascending order, turns out to be worth an order of magnitude in insert performance on a big table. More on the numbers below.
How a v7 is put together
The 128 bits of a v7 break down like this:
| Bits | Field | Content |
|---|---|---|
| 48 | unix_ts_ms | Milliseconds since 1970-01-01 UTC, big-endian |
| 4 | version | Always 0111 (7) |
| 12 | rand_a | Randomness, or extra timestamp precision |
| 2 | variant | Always 10 |
| 62 | rand_b | Randomness |
So you get 74 random bits per millisecond in the default layout. RFC 9562 explicitly allows implementations to spend the 12 rand_a bits on sub-millisecond timestamp precision or a monotonic counter instead, which is what PostgreSQL does (see below). 48 bits of milliseconds last until the year 10889, so the timestamp field won’t be your project’s Y2K.
Worth knowing: the timestamp is not obfuscated in any way. The first 12 hex characters of a v7 are the creation time. Paste 017f22e2-something into a converter and out comes a date. Keep that in mind for the privacy section.
Why random keys hurt B-trees
Databases index primary keys with B-trees, and B-trees love ordered inserts. When new keys always sort higher than existing ones, every insert lands on the rightmost leaf page. The database fills that page, allocates the next one, moves on. Postgres even has a dedicated fast path for this rightmost-page split. The hot part of the index is one page, it lives in cache, life is good.
Random keys break every part of that. A v4 lands on a random leaf page somewhere in the index, so with a 500 GB table, every insert touches an effectively random page. Consequences, in order of pain:
- Page splits. When a full page gets an insert in the middle, it splits into two half-full pages. With random inserts this happens constantly, so the index stays chronically half-empty and grows far larger than the data requires.
- Cache misses. Once the index outgrows shared memory, the random page an insert needs is probably not cached. Every insert becomes a potential disk read.
- Write amplification. In Postgres, the first change to a page after a checkpoint writes the whole page to the WAL. Random inserts dirty far more distinct pages, so the WAL balloons, and so does replication traffic.
None of this shows up in tests with 10,000 rows, where the whole index fits in cache anyway. It shows up in production two years later, when inserts have gotten mysteriously slow and the primary key index is somehow bigger than the table.

The benchmark numbers
The German PostgreSQL company credativ benchmarked exactly this on PostgreSQL 18, inserting 50 million rows into a table keyed by v4 vs v7. The bulk insert took about 1 minute 46 seconds with v7 keys, and about 20 minutes with v4. Same table, same hardware, same row count, roughly 11x slower purely because of where the keys land in the index. The primary key index came out at 1981 MB with v4 against 1504 MB with v7, so the random keys cost about a third more disk for the same rows. Inserting into a table that already held 50 million rows made it worse: 46 minutes with v4, still under 2 minutes with v7.
Other published runs land in the same neighbourhood. A benchmark series on dev.to by Umang Sinha measured insert latency at the 5-million-row mark and got roughly 4x slower inserts and a 2.7x larger index with v4. The exact multiplier depends on table size relative to RAM, but we haven’t found a serious benchmark where v4 wins. There’s no configuration in which random insert positions are good for a B-tree.
One honest caveat: on small tables the difference is close to zero. If your hottest table will never pass a million rows, this argument alone shouldn’t make you migrate anything.
uuidv7() in PostgreSQL 18
PostgreSQL 18, released in September 2025, ships a native uuidv7() function, so the whole thing collapses into one line: id uuid PRIMARY KEY DEFAULT uuidv7(). No extension, no application-side generation, no trigger.
The Postgres implementation makes a smart use of the layout freedom RFC 9562 allows: it fills the 12 rand_a bits with a sub-millisecond fraction of the timestamp. IDs generated by the same backend within one millisecond still come out in ascending order, which plain random rand_a bits wouldn’t guarantee. There’s also uuid_extract_timestamp() to pull the creation time back out of a v7 (or a legacy v1), which is genuinely handy for debugging.
On PostgreSQL 17 and older, the usual options are the pg_uuidv7 extension or generating v7 in the application. Generating in the application is underrated anyway: the ID exists before the INSERT, which makes batching and client-side references easier. That’s a benefit v4 always had, and v7 keeps it.
The timestamp you now leak
Here’s the trade-off, and it’s a real one: every v7 you expose publicly tells the world when the row was created, down to the millisecond. RFC 9562 says so itself in its security considerations; timestamp-based versions are explicitly called out as unsuitable where creation time is sensitive.
Think about what that means concretely. A v7 user ID reveals the signup date of every account. v7 invoice IDs let a competitor who sees two of them estimate your order volume, a soft version of the classic German tank problem that sequential integers have. A v7 in a password reset URL tells an attacker exactly which millisecond range to brute-force the random bits around, though 74 random bits still hold up fine against that.
So the rule we use: v7 is an identifier, v4 is a secret. Anything that works like a bearer token (session IDs, reset links, unlisted-URL sharing, API keys) gets v4 or a proper random token, because unguessability is the whole point there. And remember that an ID in a URL is visible in logs, browser history and Referer headers no matter which version you pick; if you’re tempted to hide data in an identifier, that’s the same fallacy we cover in Base64 is not encryption. Where you keep session material matters too, which is its own can of worms; see where to store JWTs.
ULID, v1 and the Melissa story
v7 didn’t appear from nowhere. The direct precursor is ULID, a 2016 spec that had the identical core idea: 48-bit millisecond timestamp plus 80 random bits, encoded as 26 characters of Crockford base32. ULIDs got popular precisely because v4 was hurting people’s databases and nothing standard existed. v7 is essentially ULID’s layout poured into the UUID mould: same timestamp width, standard hex-and-dashes formatting, and compatibility with every uuid column type and library on earth. If you use ULIDs today, there’s no urgent reason to migrate, but for new systems v7 wins on ecosystem alone.
And the reason “random” became the default in the first place is a good story. UUID v1, the original 1990s design, embedded the network card’s MAC address as its node field. In March 1999, the Melissa virus infected an estimated hundred thousand Windows machines through a Word macro, and investigators linked the original infected document to its author partly through exactly such an embedded GUID, which carried a MAC address traceable to David L. Smith’s machine. Smith was arrested on April 1, 1999, about a week after the outbreak, pleaded guilty to causing over 80 million dollars in damages, and got 20 months in federal prison. Great forensics, terrible privacy, and a large part of why the industry ran to fully-random v4 and stayed there for two decades.
v7 is the correction of that overcorrection: it keeps a timestamp (useful, mildly leaky) and drops the hardware identity (catastrophically leaky).
Our verdict
Two migrations later, the whole answer fits in one line: v7 for primary keys, v4 for secrets, and don’t overthink it beyond that. Specifically:
- New tables with UUID keys: v7, always. The insert performance is free and the rough time-ordering makes debugging nicer.
- Tokens, reset links, anything unguessable-by-design: v4 (or 32 bytes from a CSPRNG).
- Public IDs where creation time is genuinely sensitive: v4, or a v7 internally with a separate random public identifier.
- Existing v4 tables that perform fine: leave them alone. Switching the default only affects new rows, and a mixed-version column is harmless.
One last thing: a v7 key does not replace a created_at column. The embedded timestamp is whatever clock the generating machine had, unqueryable without extraction functions, and frozen in milliseconds. Store real timestamps properly, which has its own set of traps around UTC and future events; we wrote up the whole mess in how to store timestamps without regrets.
Picking a UUID version
Should I use UUID v4 or v7 for database primary keys?
Use UUID v7 for primary keys. Its first 48 bits are a Unix millisecond timestamp, so new rows land at the right edge of the B-tree index instead of at random positions, which avoids the page splits and cache misses that make v4 keys slow at scale. Benchmarks on PostgreSQL 18 show inserts running an order of magnitude faster with v7 on large tables. Keep v4 for values that must be unguessable, like session tokens or password reset links.
What is the difference between UUID v4 and UUID v7?
A UUID v4 is 122 bits of pure randomness, while a UUID v7 starts with a 48-bit Unix timestamp in milliseconds followed by 74 random bits. Both are 128-bit values in the same canonical format and both come from RFC 9562. The practical difference: v7 values generated later sort after values generated earlier, as strings and as bytes, while v4 values sort in no meaningful order at all.
Is UUID v7 predictable or a security risk?
A v7 is not guessable in practice (74 random bits per millisecond), but it does reveal exactly when it was created, because the timestamp sits unencrypted in the first 48 bits. RFC 9562 flags this in its security considerations. For an internal primary key that rarely matters; for a public token, a password reset link or an ID where the creation time itself is sensitive, use v4 instead.
How do I generate UUID v7 in PostgreSQL or MySQL?
PostgreSQL 18 (released September 2025) has a built-in uuidv7() function, so a column can simply default to it. On older PostgreSQL versions people use the pg_uuidv7 extension or generate the value in the application. MySQL’s built-in UUID() function still produces version 1, so for MySQL you generate v7 in application code; every major language has a maintained library for it.
Can UUID v7 collide?
In theory yes, in practice no. Two v7 values only risk colliding if they are generated in the same millisecond, and within that millisecond there are 74 random bits, which is about 1.9 × 10²² possible values. You would need to generate billions of IDs per millisecond before collisions become a realistic concern. Distributed systems generate v7 on many machines concurrently without coordination, same as v4.
Can you extract the creation time from a UUID v7?
Yes, trivially. The first 48 bits of a UUID v7 are the Unix timestamp in milliseconds, so the first 12 hex characters decode straight to a date. PostgreSQL 18 even ships a uuid_extract_timestamp() function for it. That is a feature for debugging and log correlation, and a leak if the ID is public and the creation time is something you would rather not share.
Are UUIDs worse than auto-increment integers as primary keys?
A bigint sequence is still the fastest and smallest option (8 bytes vs 16), so if a single database hands out all IDs, it remains a fine default. UUIDs win when IDs must be generated on multiple machines or before the row is inserted, and when you don’t want public IDs to expose row counts, since sequential integers let anyone estimate how many orders or users you have. UUID v7 closes most of the performance gap that used to be the argument against UUIDs.