What a UUID Actually Is
A UUID is 128 bits of data, usually written as 32 hexadecimal digits in five dash-separated groups of 8-4-4-4-12:
550e8400-e29b-41d4-a716-446655440000
The dashes carry no information. They exist so humans can compare identifiers by eye. The interesting part is that not all 128 bits are free: 4 bits encode the version and 2 bits encode the variant. You can read both straight off the string.
550e8400-e29b-41d4-a716-446655440000
^ ^
| variant nibble: 8, 9, a or b means RFC variant
version digit: 4 here, so this is a v4 UUID
The version digit is the first character of the third group. The variant is the first character of the fourth group, and for standard UUIDs its top two bits are 10, which restricts that hex digit to 8, 9, a, or b. Anything else is a legacy Microsoft GUID layout or not a conforming UUID at all.
The governing specification is RFC 9562, published in May 2024, which obsoletes the older RFC 4122. RFC 9562 kept versions 1 through 5 unchanged and added versions 6, 7 and 8. A guide that mentions only RFC 4122 predates v7.
Two UUIDs are reserved: the Nil UUID (00000000-0000-0000-0000-000000000000) and the Max UUID (ffffffff-ffff-ffff-ffff-ffffffffffff). Neither carries a version or variant; treat both as "no value" sentinels and never let one reach a database as a real key.
The Versions, and Which Ones Still Matter
| Version | How it's built | Sortable by time | Leaks | Use it when |
| v1 | 60-bit timestamp + clock sequence + MAC address | No (low half of the timestamp is stored first) | Creation time, host MAC | Legacy systems only |
| v3 | MD5 of namespace + name | No | Nothing directly | Interop with existing v3 data |
| v4 | 122 random bits | No | Nothing | Default choice for opaque IDs |
| v5 | SHA-1 of namespace + name | No | Nothing directly | Deterministic IDs from stable names |
| v6 | v1 fields reordered most-significant-first | Yes | Creation time, optionally MAC | Migrating a v1 system to sortable keys |
| v7 | 48-bit Unix millisecond timestamp + 74 random bits | Yes | Creation time (to the millisecond) | Database primary keys |
| v8 | Entirely vendor-defined layout | Depends | Depends | You need a custom scheme and still want the UUID container |
In practice you will use two of these. v4 when the identifier should reveal nothing, and v7 when the identifier is a database key and you want index locality. v1 has no advantage over v6 or v7 and its MAC address field is a genuine privacy problem. v3 and v5 arrived together in RFC 4122; v3 is not an earlier generation, just the MD5 half of the pair, and new deterministic IDs should use v5.
v4: Random, and How Random Is Enough
A v4 UUID has 128 bits minus 4 version bits minus 2 variant bits, so 122 bits of randomness. Generate it from a cryptographic RNG, never from Math.random().
// Browsers: secure context only (HTTPS or localhost).
// Node: global crypto from 19.0; before that,
// const { randomUUID } = require("node:crypto"); // since 14.17
const id = crypto.randomUUID();
// "3f8a1e6c-5b2d-4c7a-9e11-8d4f2b6a0c33"
import uuid
print(uuid.uuid4()) # uses os.urandom under the hood
-- PostgreSQL 13 and later, no extension required
SELECT gen_random_uuid();
The collision question comes up constantly, so here is the actual arithmetic. By the birthday bound, a 50% chance of any collision in a 122-bit space arrives after roughly sqrt(2 × 2^122 × ln 2) values, which is about 2.7 × 10^18 UUIDs. If you minted a billion v4 UUIDs every second, you would reach that point after about 86 years. Collision is not a risk you need to engineer around. A broken RNG is, which is the real reason Math.random() is disqualified.
You can produce single or bulk v4 values with the UUID Generator when you need seed data or fixtures rather than runtime generation.
v7: The One You Probably Want for Primary Keys
v7 puts a 48-bit big-endian Unix timestamp in milliseconds into the first six bytes, then the version nibble, 12 free bits, the variant bits, and 62 more random bits. Because the timestamp sits in the most significant position, sorting v7 UUIDs lexicographically as strings or numerically as bytes sorts them by creation time.
Take this v7 value:
019caef4-ba40-7a1c-9f3b-1c2d3e4f5a6b
Strip the dashes, take the first 12 hex digits — 019caef4ba40 — and read them as an integer: 1,772,461,800,000 milliseconds since the Unix epoch, which is 2026-03-02 14:30:00 UTC. You can paste that number into the Timestamp Converter to confirm it.
function v7Timestamp(uuid) {
const hex = uuid.replace(/-/g, "").slice(0, 12);
return new Date(Number(BigInt("0x" + hex)));
}
v7Timestamp("019caef4-ba40-7a1c-9f3b-1c2d3e4f5a6b");
// 2026-03-02T14:30:00.000Z
That decodability is the trade. v7 gives you B-tree friendly inserts and free creation-time ordering; in exchange, anyone holding the ID learns when the row was created to the millisecond. For an internal orders table that is fine. For a public password-reset link it is not — use v4 there, or a dedicated random token from the Password Generator.
v3 and v5: Deterministic IDs From Names
v3 and v5 hash a namespace UUID concatenated with a name, then overwrite the version and variant bits. v3 uses MD5 (128-bit digest, used whole); v5 uses SHA-1 (160-bit digest, truncated to the first 128 bits). The same namespace and name always yield the same UUID on every machine, forever.
The four predefined namespaces from the RFC:
DNS 6ba7b810-9dad-11d1-80b4-00c04fd430c8
URL 6ba7b811-9dad-11d1-80b4-00c04fd430c8
OID 6ba7b812-9dad-11d1-80b4-00c04fd430c8
X.500 6ba7b814-9dad-11d1-80b4-00c04fd430c8
import uuid
uuid.uuid5(uuid.NAMESPACE_DNS, "example.com")
#=> UUID('cfbff0d1-9375-5685-968c-48ce8b15ae17')
Any correct implementation, in any language, returns that same value. That property makes v5 useful for idempotent imports: derive the row's ID from the source system's natural key, and re-running the import updates instead of duplicating.
The security caveat matters here. As of 2026, MD5 and SHA-1 are both broken for collision resistance — MD5 collisions are trivial, and SHA-1 chosen-prefix collisions have been demonstrated in practice since 2019. Neither hash should be used for signatures, certificates, or integrity checks against an adversary. v3 and v5 are still fine for their intended job, because that job is deterministic naming among inputs you control, not tamper resistance. If an attacker can choose the name you hash, do not treat the resulting UUID as unguessable or unforgeable. If you need a digest for actual integrity work, use SHA-256 — the Hash Generator will produce one.
Storing UUIDs Without Wrecking Your Indexes
A UUID is 16 bytes. Stored as a 36-character string it is 36 bytes, plus length overhead, plus a slower comparison on every index probe. Use a native or binary column.
-- PostgreSQL: native type, 16 bytes
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
created_at timestamptz NOT NULL DEFAULT now()
);
-- MySQL 8: 16-byte binary column
CREATE TABLE orders (
id BINARY(16) PRIMARY KEY,
created_at DATETIME NOT NULL
);
INSERT INTO orders (id, created_at) VALUES (UUID_TO_BIN(UUID(), 1), NOW());
SELECT BIN_TO_UUID(id, 1) AS id FROM orders;
The second argument to UUID_TO_BIN is the swap flag. MySQL's UUID() returns a v1 value whose time-low field sorts first, which scatters inserts across the index; the swap flag reorders the fields so they sort chronologically. If you generate v7 in your application instead, pass 0 — v7 is already in sorted order and swapping would destroy it.
Two rules survive every schema review: index the UUID only where you actually look rows up by it, and never expose a sequential integer key beside it, which reintroduces the enumeration the UUID was meant to prevent.
Choosing Between UUIDs and Integer Keys
| UUID (v4/v7) | Auto-increment integer |
| Uniqueness scope | Global, across services | Per table |
| Generated by | Client or app, before insert | Database, on insert |
| Size | 16 bytes | 4 or 8 bytes |
| Enumerable | No | Yes |
| Merging shards | No renumbering needed | Key conflicts |
| Insert locality | v7 good, v4 poor | Excellent |
| Readable in a support ticket | Poor | Good |
The decisive factor is usually not performance but *who assigns the ID*. If a client, a queue consumer, or an offline mobile app has to know an entity's ID before the database sees it, you need a UUID. If every write goes through one database and nothing is ever merged across regions, an integer key is smaller, faster, and easier to say out loud.
A common middle ground is both columns: a bigint primary key for internal joins and a v4 UUID as the external, API-facing identifier. You pay 16 extra bytes per row and get non-enumerable public URLs.
Quick Checklist
- Default to v7 for new primary keys, v4 for anything exposed publicly where creation time should stay private.
- Generate from a CSPRNG.
crypto.randomUUID(),os.urandom,gen_random_uuid()— not a language's defaultrandom. - Store as 16 bytes, not 36 characters.
- Validate on input with
^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$(case-insensitive) — the third and fourth groups pin version and variant. You can try it against real samples in the Regex Tester. - Compare UUIDs case-insensitively. The RFC says output lowercase, accept either.
- Do not reuse a UUID as a secret unless it is v4 from a cryptographic RNG, and even then prefer a purpose-built token.