Guide
UUID versions, and what they do to a database index
A UUID is 128 bits with a bit of structure imposed on it. Which structure depends on the version, and the version you pick has consequences well past uniqueness — it decides whether your primary key index appends neatly or scatters writes across the whole table.
The anatomy
The canonical form is 32 hex digits in five dash-separated groups, 8-4-4-4-12:
6ba7b810-9dad-11d1-80b4-00c04fd430c8
│ │
│ └── variant bits (first 1-3 bits of this digit)
└── version (this whole hex digit)
Two fields are carved out of the 128 bits. The 13th hex digit is the version — 1 through 8. The first bits of the 17th are the variant, and for essentially every UUID you will meet they are 10, meaning the RFC 4122/9562 layout; that makes the 17th digit one of 8, 9, a, or b.
Those six bits are not available for data, which is why a "random" UUID does not carry 128 bits of randomness. It carries 122.
The versions
- v1 — time and MAC. A 60-bit timestamp in 100-nanosecond intervals since 1582, plus a clock sequence, plus the machine's MAC address. Sortable-ish, but it embeds the hardware address and generation time in every ID you hand out. That is a privacy and fingerprinting problem, and it is how the author of the Melissa virus was traced.
- v3 and v5 — name-based. Deterministic: hash a namespace UUID plus a name, take 128 bits. v3 uses MD5, v5 uses SHA-1. Same input, same UUID, forever, on any machine. Genuinely useful when you need a stable ID derived from something you already have — a URL, a DNS name, a tenant plus an external key.
- v4 — random. 122 random bits, six fixed. What almost everyone means by "UUID".
- v6 — reordered v1. Same fields as v1, rearranged so the timestamp is most-significant-first and therefore sorts lexicographically. A migration path for systems already on v1.
- v7 — Unix time plus random. A 48-bit millisecond Unix timestamp, then 74 random bits. Time-ordered, no MAC address, no epoch from the sixteenth century.
- v8 — custom. A blessed space for application-defined layouts that still want to be a well-formed UUID.
v6, v7, and v8 were standardised in RFC 9562 in 2024, which also replaced RFC 4122. Library support is now broad — Python 3.14, .NET 9, PostgreSQL 18, and the major Java, Go, and JavaScript UUID packages all generate v7.
Is v4 actually unique?
For practical purposes, yes, and the arithmetic is worth internalising once so you stop worrying about it.
With 122 random bits, the birthday bound puts a 50% chance of any collision at roughly 261 UUIDs — about 2.3 × 1018. Generating a billion per second, that is around 73 years to reach even odds of a single duplicate anywhere in the set.
The real risk is not the maths. It is the source of the randomness.
- A v4 built on
Math.random()is not unique and not unguessable.Math.random()is a fast PRNG with a small internal state, seeded per process; two processes starting together can produce the same stream. - A v4 from a CSPRNG —
crypto.randomUUID(),crypto.getRandomValues(),os.urandom,/dev/urandom— has the properties above. - Embedded devices and VMs cloned from a snapshot have historically had weak entropy at first boot, which is a real source of duplicates in the field.
Use the platform's built-in generator. crypto.randomUUID() exists in Node and in every current browser, and does the right thing.
What a random primary key does to a B-tree
This is the practical reason the version matters, and it is invisible until the table is large.
A B-tree index keeps its entries in sorted order across fixed-size pages. Insert an auto-incrementing integer and every new row lands at the right-hand edge — the same page, already in memory, appended to until it fills and a fresh one is allocated. Sequential, cache-friendly, minimal write amplification.
Insert a v4 UUID and the key is uniformly distributed across the whole keyspace. Every insert targets a random page. Consequences, roughly in order of how much they hurt:
- The working set becomes the entire index rather than its tail. Once the index outgrows RAM, each insert is a disk read as well as a write.
- Pages fill unevenly and split mid-page, so the index carries more free space and grows larger than a sequential one holding the same rows.
- On engines that cluster the table by primary key — InnoDB always, SQL Server by default — the table rows are physically scattered too, not just the index.
- Any query for "the most recent N" loses the locality that would have made it a short range scan.
v7 fixes this by putting a millisecond timestamp in the most significant 48 bits. Consecutive inserts land in adjacent keyspace, the index appends, and you keep the property that made UUIDs attractive: an ID that can be generated anywhere, by any client, without coordination.
The trade is that a v7 leaks its creation time to anyone holding it, to the millisecond. Usually harmless, occasionally not — if IDs are public and creation order is sensitive, that is a real disclosure.
Storage: 16 bytes, not 36
A UUID is 128 bits. The canonical text form is 36 characters, so storing it as CHAR(36) costs 36 bytes — and worse in a UTF-8 column with a multi-byte-aware collation, where comparisons are not simple byte comparisons.
- PostgreSQL — a native
uuidtype, 16 bytes, with text input and output. Use it.gen_random_uuid()is built in; v7 generation arrived in PostgreSQL 18. - MySQL/MariaDB — no native type.
BINARY(16)withUUID_TO_BIN()andBIN_TO_UUID(). The optional second argument toUUID_TO_BIN()swaps the time fields of a v1 to make it sortable — unnecessary if you are generating v7. - SQL Server —
uniqueidentifier, 16 bytes, but note it sorts in a byte order that is not the string order, which surprises people comparing plans across engines. - SQLite — no native type;
BLOBof 16 bytes.
On a 50-million-row table the difference between 16 and 36 bytes per key, multiplied across the primary key and every foreign key and every index that includes it, is measured in gigabytes.
When a UUID is the right key — and when it is not
Reach for one when the ID has to be created without asking the database: offline-capable clients, IDs assigned before a row is inserted, records merged from multiple shards or systems, or anywhere a round trip for an ID is a real cost.
Also when exposing a sequential integer would be a problem. /invoices/1042 tells a competitor how many invoices you have issued and invites walking the range; /invoices/018f4a… does not. Note that this is about enumeration, not authorisation — an unguessable ID is not an access control, and every one of those endpoints still needs a permission check.
Against: a UUID is four times the size of a 32-bit integer, unreadable over the phone, tedious in a debugging session, and — if you pick v4 — costs you the index behaviour above. For a single-database application with server-assigned IDs and no enumeration concern, bigint identity is still an excellent primary key.
A common middle path is both: a bigint primary key for internal joins and foreign keys, plus a UUID column with a unique index as the external identifier. You get compact internal references and opaque public URLs, at the cost of one extra column and one extra index.
Not a security token
A v4 from a CSPRNG has 122 bits of entropy, which is genuinely enough to be unguessable. But the version and variant bits are fixed and visible, some libraries do not use a CSPRNG, and a v1 or v7 has most of its bits determined by the clock. Reading "UUID" in code tells you nothing about which of those you got.
For a password reset link, a session ID, or an API key, generate the bytes directly and encode them — crypto.randomBytes(32).toString('base64url') — so the entropy is explicit and nobody has to audit which UUID version a dependency happens to emit.
Last updated 10 August 2026