You've generated thousands of UUIDs without thinking twice. id column, default value, done — a 36-character string that looks like f47ac10b-58cc-4372-a567-0e02b2c3d479 shows up, and you move on. Most developers treat UUIDs as a black box: "long random thing, guaranteed unique, don't ask questions."

Except it isn't guaranteed, there are eight different versions with real tradeoffs, and the most common version is quietly one of the worst choices for a database primary key at scale. This guide covers what a UUID actually is, what "unique" really means, which version to reach for and why, the alternatives worth knowing, and how to generate and parse them correctly across languages.

What a UUID actually is

A UUID (Universally Unique Identifier, also called a GUID — Globally Unique Identifier — in Microsoft ecosystems) is a 128-bit number, almost always written as 32 hexadecimal digits split into five groups by hyphens:

f47ac10b-58cc-4372-a567-0e02b2c3d479
└──8───┘ └4┘ └4┘ └4┘ └────12────┘

128 bits is enormous — 2128 possible values, roughly 340 undecillion (that's 340 followed by 36 zeros). But not all of those bits are free. A few are reserved to encode the UUID's version (which algorithm generated it) and variant (which layout rules it follows), which is how a UUID string tells you something about its own origin just by looking at it — the version number sits right in the third group.

The "guaranteed unique" myth

UUIDs are not guaranteed unique. They are probabilistically unique — collisions are possible, just astronomically unlikely for the common random version. It's worth actually doing the math instead of repeating the folklore.

A version 4 (random) UUID has 122 bits of actual randomness — 128 bits minus 6 fixed bits for version and variant. Using the standard birthday-paradox approximation for collision probability, you'd need to generate about 2.71 quintillion random v4 UUIDs before the odds of a single collision anywhere in that set reach 50%. Put another way: generating 1 billion UUIDs every second, continuously, would take roughly 85 years to reach even odds of one collision, ever, across the entire set.

For essentially every real-world system, that risk is not worth worrying about. The actual sources of "duplicate ID" bugs in practice are almost never a genuine v4 collision — they're a broken random number generator, a UUID copy-pasted into test fixtures and never regenerated, or a non-random version (like v1) that isn't drawing from that same 122-bit pool in the first place.

The version zoo

The current UUID specification is RFC 9562 (2024), which formalized versions 6, 7, and 8 alongside the older ones. Here's what each version actually is:

  • v1 — timestamp + MAC address. Encodes the generating machine's network MAC address and the current time. Sortable-ish, but leaks machine identity and generation time, and the timestamp field ordering is not straightforwardly sortable as a string. Rarely the right choice today.
  • v3 — name-based (MD5). Deterministic: hashing the same namespace + name always produces the same UUID. Useful for generating a stable ID from an existing identifier (a URL, an email). Uses MD5, which is fine here since this isn't a security context — just not collision-resistant against a determined adversary.
  • v4 — random. The default in most libraries and ORMs. 122 bits of randomness, no embedded metadata, unpredictable. The most common version by far.
  • v5 — name-based (SHA-1). Same idea as v3, better hash. Prefer v5 over v3 if you need deterministic, name-based UUIDs.
  • v6 — reordered timestamp. A fix for v1: same timestamp+node concept, but with the time bits reordered so the UUID sorts correctly as a plain string. A bridge for systems migrating away from v1.
  • v7 — Unix timestamp + random. A 48-bit millisecond Unix timestamp up front, followed by random bits. Sorts chronologically as a string, which v4 fundamentally cannot do. Increasingly the recommended default for anything that touches a database — more on why below.
  • v8 — custom. A blank canvas: the spec reserves the version/variant bits but leaves the rest of the layout up to you, for vendor-specific formats that still want to look like a standard UUID to generic tooling.

Why v4 became the default — and why that's changing

v4 won by default because it's the simplest possible answer: grab 122 random bits, set six fixed bits, done. No machine identifiers to leak, no clock to manage, no namespace to define. For years, "just use v4" was uncontroversial advice.

The catch only shows up at scale, and it shows up specifically as a database problem.

The database mistake: random UUIDs as primary keys

This is the part most UUID explainers skip, and it's the one that actually costs teams real performance.

Relational databases store rows physically ordered (or at least clustered) by their primary key in a B-tree index. When you insert a new row with a sequential key (an auto-increment integer, or a v7 UUID), the database appends it to the rightmost edge of the tree — cheap, cache-friendly, no reshuffling.

When you insert a row with a random key — any v4 UUID — the database has to insert it at a random location somewhere in the middle of the tree. At small scale you won't notice. At scale, this causes:

  • Page splits. Random inserts constantly land in already-full index pages, forcing the database to split them, which is expensive and fragments the index further.
  • Poor cache locality. Sequential inserts stay warm in memory. Random inserts touch pages scattered across the entire index, defeating the buffer pool / page cache.
  • Index bloat. Fragmented B-trees end up larger on disk than they need to be, which means more I/O for every query that uses the index — not just inserts.

This is a well-documented, repeatedly-rediscovered pain point in both MySQL (where it hits InnoDB's clustered index especially hard, since the primary key is the physical row order) and PostgreSQL (where it causes heap and index bloat as pages fill unevenly).

The fix isn't "don't use UUIDs" — it's "don't use a random UUID as your insert order." Three real options:

  1. Use UUIDv7 for primary keys. Because it leads with a timestamp, new rows insert in roughly chronological — and therefore roughly sequential — order, which behaves like an auto-increment key for indexing purposes while still being a globally unique, database-independent ID you can safely generate in application code before the row exists.
  2. Use an integer primary key, UUID as a secondary column. Auto-increment (or a sequence) stays the physical/clustering key for performance; a UUID column, indexed separately, is what you expose externally (in URLs, APIs) so you're not leaking sequential row counts.
  3. Use your database's native sequential UUID support if it has one. PostgreSQL 18 added a built-in uuidv7() function; earlier Postgres versions and other databases can generate v7 in application code and insert the value directly.

If you're starting a new table today with no strong reason to do otherwise, v7 as the primary key is the pragmatic modern default — you get the operational benefits of UUIDs (decentralized generation, no coordination between services or shards) without the B-tree penalty of pure randomness.

ULID and nanoid — the alternatives worth knowing

ULID (Universally Unique Lexicographically Sortable Identifier) solves the same problem v7 solves, via a different encoding. It's also 128 bits — a 48-bit millisecond timestamp plus 80 bits of randomness — but it's represented as a 26-character Crockford Base32 string instead of hyphenated hex, which makes it shorter, case-insensitive, and free of characters that are easy to misread (no I, L, O, or U). ULID predates UUIDv7 and has a mature ecosystem; if your team already uses ULIDs, there's little reason to switch.

nanoid takes a different angle entirely: it isn't a UUID variant at all, just a small, fast, URL-safe random string generator. The default output is 21 characters from a 64-character alphabet — noticeably shorter than a UUID's 36 characters, with comparable collision resistance for most application purposes. It's popular for short IDs in URLs (slugs, share links) where a full UUID feels like overkill.

Formatting and parsing gotchas

Case sensitivity. UUIDs are technically case-insensitive — the hex digits can be upper or lower case and represent the same value — but different systems normalize inconsistently. Always lowercase (or uppercase) before storing or comparing, and don't assume two differently-cased strings won't match.

Braces and prefixes. Microsoft/.NET GUIDs are often wrapped in braces ({f47ac10b-58cc-4372-a567-0e02b2c3d479}), and the urn:uuid: prefix shows up in some XML/RDF contexts. Strip these before parsing if you're integrating across platforms.

Byte-order mismatch. This one is genuinely obscure and genuinely painful: .NET's Guid type stores the first three field groups (time-low, time-mid, time-hi-and-version) in little-endian byte order internally, while the RFC's string representation — and most other languages and databases — treat those same bytes as big-endian. If you're comparing or converting raw bytes between a .NET system and, say, a PostgreSQL uuid column, the string representations can match while the raw byte arrays don't, or vice versa. Always convert through the string form when crossing that boundary, never raw bytes.

The nil UUID. 00000000-0000-0000-0000-000000000000 has a defined meaning — "no value" / a sentinel — not just "a UUID that happens to be all zeros." Don't reuse it as a real identifier.

Storing UUIDs efficiently

Storing a UUID as a plain 36-character string works, but wastes space and slows down index comparisons compared to the alternatives:

  • PostgreSQL has a native uuid type — 16 bytes on disk, indexed and compared far more efficiently than text or char(36). Use it.
  • MySQL historically lacked a native UUID type; the common workaround is storing as BINARY(16) using UUID_TO_BIN() / BIN_TO_UUID() to convert on the way in and out, which cuts storage roughly in half versus a 36-character CHAR column and speeds up index lookups.
  • Don't index the text representation if a native or binary type is available — comparing 36-character strings is meaningfully slower than comparing 16 raw bytes.

Generating UUIDs in JavaScript

// Built-in, no dependency — Node 14.17+, all modern browsers
crypto.randomUUID();
// → "f47ac10b-58cc-4372-a567-0e02b2c3d479"  (always v4)

// For v1, v3, v5, v7 and more, use the uuid package
import { v4 as uuidv4, v7 as uuidv7 } from 'uuid';

uuidv4(); // random
uuidv7(); // timestamp-ordered — better as a DB primary key

Generating UUIDs in Python

import uuid

uuid.uuid4()   # random — most common
uuid.uuid1()   # timestamp + MAC — leaks machine info, rarely what you want
uuid.uuid5(uuid.NAMESPACE_DNS, "example.com")  # deterministic, name-based

# Python 3.14+ — v6, v7, v8 are now in the standard library
uuid.uuid7()

# Python 3.13 and earlier — no stdlib v7, use a third-party package
# pip install uuid-utils
import uuid_utils
uuid_utils.uuid7()

Generating UUIDs in SQL

-- PostgreSQL 13+ — built into core, no extension needed
SELECT gen_random_uuid();

-- PostgreSQL 18+ — native, sortable, ideal for primary keys
SELECT uuidv7();

-- MySQL — v1-style (time + node) by default
SELECT UUID();

-- Efficient binary storage in MySQL
INSERT INTO users (id) VALUES (UUID_TO_BIN(UUID()));
SELECT BIN_TO_UUID(id) FROM users;

-- SQL Server
SELECT NEWID();

Generating UUIDs on the command line

# macOS and most Linux distros
uuidgen
# → F47AC10B-58CC-4372-A567-0E02B2C3D479

# Lowercase it if the target system expects that
uuidgen | tr '[:upper:]' '[:lower:]'

How to avoid the pain

  • Stop assuming "unique" means "guaranteed." It's probabilistic — vanishingly unlikely to matter, but not impossible, and real bugs almost always trace back to bad randomness or reused test values, not a genuine collision.
  • Default to v7 for anything that becomes a database primary key. v4 is still fine for tokens, external identifiers, and anything that isn't driving index insert order.
  • Use your database's native UUID type (or binary storage) instead of a plain string column.
  • Normalize case before storing or comparing UUID strings.
  • Never compare raw UUID bytes across a .NET boundary — go through the string form to sidestep the byte-order mismatch.
  • Consider ULID or nanoid when you want sortability or a shorter representation and don't need to stay within the strict UUID spec.

None of this is complicated once you know it. The default advice of "just call uuid4() and move on" is fine for 90% of use cases — it's the other 10%, almost always a high-write-volume database table, where knowing the difference between v4 and v7 actually saves you a production incident.