francescomargiotta.com
  • Backend
  • Data

UUID v4 or v7: which to use as a primary key

v4's pure randomness fragments B-tree indexes. v7 puts a timestamp in the high bits and restores insert locality, at the cost of revealing when it was created.

by Francesco Margiotta Casaluci6 min read

Choosing UUIDs as your primary key looks like a purely architectural decision and turns out, months later, to be a performance decision. Version 4 — what almost everyone means when they say "UUID" — has a property relational databases dislike: it is completely random. Version 7, standardised in 2024 by RFC 9562, fixes exactly that, and it is worth understanding what actually changes.

How a UUID is built

A UUID is a 128-bit identifier, usually written as 32 hexadecimal digits in five hyphen-separated groups. Not all 128 bits are free: a few are reserved to declare which version and which variant the UUID is.

85a12264-ce80-4656-a95c-1ad65aed3241
                 ↑    ↑
                 │    └── variant (the leading bits of this group)
                 └─────── version (here: 4)

The digit opening the third group carries the version; the high bits of the fourth group carry the variant. That is six bits taken out of the available space: a version 4 UUID therefore has 122 genuinely random bits, not 128.

Version 4: randomness and nothing else

A v4 UUID is 122 bits from a cryptographic random generator, full stop. No timestamp, no machine identifier, no counter. That is the source of its popularity: it can be generated anywhere — in the client, in a service, in a test — with no coordination and no practical collision risk.

v4 UUIDs generatedProbability of at least one collision
1 billion (10⁹)about 9 × 10⁻²⁰
1 trillion (10¹²)about 9 × 10⁻¹⁴
1 quadrillion (10¹⁵)about 9 × 10⁻⁸
2.3 × 10¹⁸50%
Birthday approximation over a space of 2¹²² values.

In practical terms: collisions are not the thing to worry about. The problem is elsewhere, and it has nothing to do with uniqueness.

Why pure randomness hurts the database

Relational database indexes are B-trees, and B-trees keep keys in order. When the primary key is increasing — an auto-incrementing integer, say — new rows all land in the last page of the index. That page is already in memory, it fills up, another opens, and so on.

With a random key every insert lands at an unpredictable point in the index. The consequences compound:

  • Every insert touches a different page, almost never already cached: the database must read it from disk before it can modify it.
  • Pages fill halfway and then split, because values arrive out of order. The index takes more space than the data would justify.
  • The useful portion of the cache shrinks: instead of working on a few hot pages, the database keeps touching cold ones.
  • Rows inserted at the same moment end up physically far apart, so reading "today's orders" means jumping across the whole table instead of reading contiguous blocks.

Version 7: time first, randomness after

RFC 9562 introduces a format that keeps v4's useful properties and removes the harmful one. The first 48 bits are the Unix timestamp in milliseconds; the rest is random, with the usual bits reserved for version and variant.

01a00f45-6040-7ecf-acc8-989be3043f7f
01a00f45-6041-7c86-943f-4a61a9216ae4
01a00f45-6042-7f2e-a672-9d909e3d0100
└──────┘ └──┘ ↑
    │      │   └── version 7
    │      └────── milliseconds (low part): 6040, 6041, 6042
    └───────────── timestamp (high part), identical within the same period
Three v7 UUIDs generated one millisecond apart. The shared prefix is what makes inserts sequential.

Because the timestamp occupies the most significant bits, the lexicographic order of a v7 UUID matches the chronological order of generation. For the B-tree that means a return to increasing-key behaviour: appends at the tail, hot pages, no fragmentation from random splits. The remaining 74 random bits still guarantee that two UUIDs generated in the same millisecond on different machines do not collide.

Side by side

UUID v4UUID v7
Random bits12274
Time-sortableNoYes
Insert localityNoneSequential
Reveals creation timeNoYes, to the millisecond
Generatable without coordinationYesYes
Library supportEverywhereWidespread but not universal

How to store them

This is the best effort-to-benefit optimisation available, and it is very often got wrong. A UUID is 16 bytes. Its textual representation is 36 characters. Storing it as a string more than doubles the footprint, and the cost multiplies across every index and every foreign key referencing it.

DatabaseCorrect typeFootprint
PostgreSQLuuid16 bytes
MySQL / MariaDBBINARY(16)16 bytes
SQL Serveruniqueidentifier16 bytes
Any, done wrongVARCHAR(36) / CHAR(36)36+ bytes

Across ten million rows with three indexes that include the key, the difference is on the order of hundreds of extra megabytes that must pass through the cache — and cache is precisely the resource we were trying to protect.

UUID or numeric key?

It is worth remembering the alternative exists. A 64-bit auto-incrementing integer takes half the space, is naturally ordered, reads easily in logs and URLs, and is the fastest of the lot. In exchange it is predictable — enumerable, and one identifier tells you roughly how many exist — and it needs the database to be assigned.

  • Single system, identifiers not publicly exposed: a numeric key is perfectly fine, and it is the simplest choice.
  • Identifiers generated by the client, or before the row exists, or by separate services that later converge: you need a UUID.
  • Identifiers exposed in public URLs: UUID, so volumes stay private. And if the creation moment is sensitive too, v4.
  • Many systems adopt both: an internal numeric key for relations, a public UUID for the outside. It costs one column and one index, and cleanly decouples the two concerns.

In short

For a primary key in a relational database, v7 is today the reasonable default: it keeps v4's distributed generation and gives the B-tree back the locality v4 takes away. v4 remains right when the creation time must not be inferable, or when the identifier never lands in an ordered index — tokens, idempotency keys, correlation ids in logs. Either way, store them in 16 bytes.

The author

Francesco Margiotta Casaluci is a backend engineer: he designs and builds microservices in Java and Spring Boot, data pipelines and cloud-native platforms. He writes about what he implements, and he implements the free tools published on this site.

Read the full profile

Related articles