Use UUID v4 unless you have a specific reason not to. Reach for UUID v7 when insert-heavy B-tree index locality shows up in an actual profile — not because a blog post said random keys are slow. Reach for ULID over v7 only when the shorter, case-insensitive text form is a feature you will genuinely use and nothing in your stack insists on a real UUID. Reach for CUID2 when you need collision resistance across many uncoordinated generators and you specifically do not want creation timestamps embedded in the ID — accepting that CUID2, like v4, gives you no index locality at all.
That's the whole answer. The rest of this is why each clause is in there, because every one of them is a tradeoff someone will try to talk you out of.
Why v4 is still the default
The case against UUID v4 is real but narrow. Random 128-bit keys land at random leaves in a B-tree, so a high-insert table fragments pages and its hot working set grows past cache. That's it. That's the entire complaint.
It only bites when writes are heavy enough and the table is large enough that the index no longer fits in RAM. If you are inserting a few hundred rows a second into a table with ten million rows, you will not measure it. People switch ID formats to fix a problem they have never seen in a query plan, and then discover their ORM, their uuid column type, and half their client libraries all had opinions about it.
Everything else about v4 is an advantage: it is in RFC 9562, every language has it, Postgres has generated it natively as gen_random_uuid() for years, and it embeds nothing — no timestamp, no MAC address, no counter. 122 bits of randomness and no story attached.
If you cannot point at an
EXPLAINplan, an index bloat metric, or a p99 write latency graph that changed, you do not have an index locality problem. You have a preference. Preferences are fine — just don't pay a migration for one.
The decision matrix
Not a spec sheet — these are the questions people actually have when they're choosing.
| What you're deciding on | UUID v4 | UUID v7 | ULID | CUID2 |
|---|---|---|---|---|
| Text length | 36 chars | 36 chars | 26 chars | 24 chars (default) |
| Sorts by creation time | No | Yes (ms) | Yes (ms) | No, deliberately |
| Reveals when it was created | No | Yes | Yes | No |
| Appends to the right of a B-tree | No | Yes | Yes | No |
Fits a native uuid column | Yes | Yes | No | No |
| Standardized | RFC 9562 | RFC 9562 | Community spec | Reference impl only |
| The database can generate it | Yes, everywhere | Postgres 18+ only | No | No |
| Library maturity | Universal | Growing | Good, per-language | Strong in JS, ports vary |
Most format arguments are really about the B-tree row. The row people forget until it's in production is "reveals when it was created."
For the mechanics of how v7 packs a 48-bit millisecond timestamp into the high bits while v4 fills them with entropy, I wrote that up separately in UUID versions explained: v4, v7, and ULID. This article assumes you already believe the ordering difference exists and is asking what to do about it.
UUID v7: the upgrade with the fewest sharp edges
If you have measured an index problem, v7 is the answer with the least blast radius. It is still 128 bits in the 8-4-4-4-12 shape, so it still drops into a uuid column, still validates against every UUID regex in your codebase, and still passes through client libraries that check the format.
The catch is generation. PostgreSQL only got native uuidv7() in version 18 — it is genuinely absent from the PostgreSQL 17 function list and present in current, with an optional shift interval argument for backdating. On anything older, or on MySQL, you generate in the application.
MySQL deserves a specific warning here, because its documented UUID advice predates v7 and quietly contradicts it. MySQL 8.4 has no native v7 function at all — UUID() still returns a version 1 value. And UUID_TO_BIN(x, 1), the swap-flag trick the docs recommend to "improve indexing efficiency," exists purely because v1 puts its time-low bytes first. Applying it to a v7 value reorders the very bytes that made v7 monotonic and throws away the locality you switched formats to get. Store v7 as BINARY(16) with swap flag 0.
-- MySQL: v7 generated in the app, stored compactly, NOT swapped
INSERT INTO orders (id, ...) VALUES (UUID_TO_BIN(?, 0), ...);
Generate a handful of v4 and v7 values with the UUID generator and put them in a column next to each other — the shared leading prefix on consecutive v7s makes the whole argument obvious in about three seconds.
ULID: the same idea, worse compatibility, nicer strings
ULID solves the identical ordering problem with a 48-bit timestamp and 80 random bits, encoded as 26 characters of Crockford base32:
01ARZ3NDEKTSV4RRFFQ69G5FAV
It is genuinely nicer to read, to double-click, and to paste into a URL. But it has no RFC, no native database column type, and the compatibility bill comes due in small annoying places: your uuid column rejects it, your OpenAPI format: uuid annotation is now a lie, and anything that validated the hyphenated shape needs changing.
The honest position: if you are choosing between ULID and v7 today and both are new to your stack, pick v7. ULID earns its place when the text form is load-bearing — IDs that humans read aloud, type, or scan in logs — and you have already checked that nothing downstream insists on real UUIDs.
CUID2: the one that isn't trying to be sortable
This is where the comparison stops being about index pages. CUID2 is 24 lowercase base36 characters by default, always starting with a letter so it's safe as a CSS class or a JS identifier:
tz4a98xxat96iws9zmbrgj3a
Its construction is different in kind from the other three. Rather than laying out a timestamp and some entropy in fixed bit positions, it hashes a set of entropy sources together with SHA3-512: system time, a pseudorandom value, a session counter, a host fingerprint, and a per-ID random salt. Nothing is recoverable from the output. The README's own collision estimate is that you'd need roughly 4×1018 IDs at the default length to reach a 50% chance of a collision.
The design goal is right there in the v1-to-v2 rationale: the original CUID leaked details including the exact creation time, and CUID2 hashes all entropy sources specifically so it doesn't. The host fingerprint is what makes independent generators — separate services, offline clients, edge workers — safe to mint IDs without a central sequence coordinating them.
Now the part CUID2 advocacy usually skips.
Because the output is a hash, CUID2 has exactly the same B-tree locality problem as UUID v4. It is not a middle ground between v4 and v7 — it is v4's write pattern with a different alphabet. The library's own README says the IDs are not k-sortable and tells you to keep a separate indexed
createdAtcolumn if you need chronological ordering.
So choosing CUID2 buys you collision resistance across uncoordinated generators and guaranteed non-leakage of creation time, and it costs you index locality plus one extra index. That's a coherent trade for a distributed, privacy-conscious system. It is not a trade you make casually, and it is the wrong answer to "my inserts are slow."
One more caveat worth knowing before you commit: the reference implementation is JavaScript, and ports to other languages vary in quality and in how faithfully they reproduce the fingerprinting. If your IDs are minted from three languages, check all three before standardizing on it.
FAQ
Can I switch formats later without a migration nightmare?
Changing the format of an existing primary key means rewriting every foreign key that points at it, which is why "we'll change it later" is rarely as cheap as it sounds. What is cheap: switching new tables to a new format and leaving old ones alone. Mixed formats in one system are ugly but survivable — mixed formats in one column are not, so if you must migrate, add a new column, dual-write, backfill, swap the constraint, then drop. If you might ever move between UUID-shaped and non-UUID-shaped formats, storing as text from day one costs you a few bytes and saves you a type change; storing as a native uuid is faster but pins you to the UUID family.
Does ULID or UUID v7 leak creation time in a way that matters?
Yes, and whether it matters is a product question, not a security one. Anyone holding the ID can read the millisecond it was created. That exposes signup order, lets someone estimate how many records you create per hour, and correlates two IDs created in the same request. For most internal entities that's fine. For anything where creation time is itself the sensitive fact — medical records, moderation reports, anything with a compliance surface — it's a real leak, and it's the strongest argument for CUID2. Separately and always: never treat an unguessable-looking ID as an authorization mechanism. That's what access checks are for.
What do Postgres and MySQL actually recommend?
Postgres 18 ships uuidv4() and uuidv7() as first-class functions and describes v7 as the time-ordered option — that's about as close to an endorsement as the docs get, and gen_random_uuid() remains the v4 default. MySQL has no v7 function and its UUID_TO_BIN swap-flag guidance is a v1-era remedy that you should not apply to v7 values (see above). Neither database has a native type for ULID or CUID2; both are VARCHAR/CHAR or BYTEA/BINARY and you own the validation.
Is CUID2 secure enough to use as a token?
No, and the project doesn't claim it is. It's built for collision resistance, not unpredictability against an attacker. Session tokens, password reset links, and API keys need a CSPRNG and a threat model, not an ID scheme.
The real mistake isn't picking the wrong format — it's picking one to solve a problem you haven't measured, and paying the compatibility tax forever. Start at v4. Move to v7 when a profile tells you to, and stay inside the UUID envelope while you do it. Take ULID when the string itself is the point. Take CUID2 when uncoordinated generation and timestamp privacy are actual requirements, and budget the extra createdAt index while you're at it. If you want to eyeball the shapes before committing, the CUID2 generator will give you a batch to compare against the UUIDs above.
Cover photo by Sergei Bezborodov on Pexels.
