UUID v4 vs v7 vs ULID: Picking an ID Format

2026-03-27 · SPUNK13 · spunk.bet

Choosing an identifier format looks like a five-minute decision and then follows a table for its entire life. The relevant differences are not aesthetic — they are index write amplification, whether the ID leaks a creation time, and how the value sorts.

What the Versions Actually Are

The Index Problem With v4

A B-tree writes in key order. Sequential keys append to the rightmost leaf page, which stays hot in the buffer pool. Random v4 keys scatter inserts uniformly across every leaf page in the index, so on a table larger than RAM each insert can touch a page that must first be read from disk. On large tables this is the difference between an index that stays compact and one that fragments and doubles in size. Time-ordered keys also let range scans on creation time hit contiguous pages.

If you are on Postgres and want a random ID without the write pattern, the alternative most teams pick is a bigint identity primary key for storage plus a random public-facing ID column, indexed separately. That keeps the hot path sequential and still avoids exposing row counts.

What v7 Leaks

A UUIDv7 discloses its creation time to the millisecond, and consecutive IDs reveal the order records were created. For an internal orders table that is fine and often useful. For a password reset token, an invite code, or anything a user could enumerate, it is a real leak — use v4 or a cryptographically random opaque token there. Two different jobs, two different formats, and there is nothing wrong with a schema using both.

Generating Them

# shell
uuidgen                 # v4 on macOS, -r/-t flags on util-linux
python -c "import uuid; print(uuid.uuid4())"

# Postgres 13+: built in, no extension needed
SELECT gen_random_uuid();
-- Postgres 18 adds uuidv7(); before that, use pg_uuidv7 or generate in the app

# Node 19+
crypto.randomUUID()

# Python 3.14 adds uuid.uuid7(); earlier versions use the uuid6 package

Never hand-roll a UUID from Math.random() or random.random(). Those are not cryptographically secure and, more practically, seeded PRNGs in forked worker processes produce identical sequences — which is how you end up with duplicate primary keys across four Gunicorn workers that all forked after the seed.

Storage: Bytes, Not Strings

A UUID is 16 bytes. Stored as a char(36) string it is 36 bytes plus overhead, and every comparison becomes a string comparison. Use the native type: uuid in Postgres, BINARY(16) in MySQL, uniqueidentifier in SQL Server. On a 50-million-row table with three indexes on the column, that is a difference measured in gigabytes.

Collisions Are Not Your Problem

With 122 random bits, you would need on the order of a billion v4 UUIDs per second for decades before a collision became likely. The realistic failure modes are all implementation bugs: a weak RNG, a truncated ID stored in a too-small column, or an ID generated client-side and trusted server-side. Guard against those; do not add retry logic for collisions.

Practical Default

For new tables where rows are created in time order and the ID is internal, use UUIDv7 or a bigint. For public tokens and anything guessable-sensitive, use v4 or 32 bytes from a CSPRNG rendered as base64url. Do not migrate an existing v4 table without a measured problem — rewriting a primary key across foreign keys is a week of work to fix a page-cache issue you may not have.

Explore More

Free tools, guides, and resources.

Visit spunk.bet
400+ ToolsCasinoMemesAstrologyScam DB