← blog

UUIDv4, UUIDv7, ULID, NanoID: IDs That Sort and Don't Leak

An ID is a small decision that follows your data around forever. Pick a random one and your database index pays for it on every insert. Pick a sequential one and you may hand an attacker a way to walk your whole table. The four common choices — UUIDv4, UUIDv7, ULID, and NanoID — trade off along two axes that rarely get discussed together: how well they sort, and how much they leak.

The four, briefly

All four are roughly 122 to 126 bits of identifier, but they spend those bits differently.

  • UUIDv4 — 122 random bits, formatted as 36 characters. Defined in RFC 9562, which obsoleted RFC 4122 in May 2024. No order, no structure, nothing to leak. The default everywhere for a reason.
  • UUIDv7 — a 48-bit Unix-millisecond timestamp in the high bits, then 74 bits of randomness (or counter), same 36-character format. New in RFC 9562. Sorts by creation time.
  • ULID — 48-bit millisecond timestamp plus 80 random bits, encoded as 26 characters of Crockford base32 (no I, L, O, U). Also time-sortable, more compact than a UUID, case-insensitive.
  • NanoID — 21 URL-safe characters by default, about 126 bits of randomness. Per the project, a bigger alphabet packs roughly the same entropy as UUIDv4 into a shorter string. Fully random, no time component.

You can generate all four in bulk at exl.ink/id, built in the browser with crypto.getRandomValues so the values never touch a server.

Why random IDs hurt your index

A database primary key usually lives in a B-tree. When the key is sequential — an autoincrement integer, or a time-ordered ID — every insert lands at the right-hand edge of the tree. The same few pages stay hot in cache, they fill up and split cleanly, and the index stays compact.

A random UUIDv4 lands somewhere new on every insert. That scatters writes across the whole tree, so pages split in the middle, end up half-full, and the working set blows past the buffer cache. The effect is measurable: benchmarks on PostgreSQL show random UUID keys producing more page splits, a roughly 22% larger primary-key index, and slower inserts than time-ordered keys carrying the same data.

UUIDv7 and ULID exist mostly to fix this. The leading timestamp makes them roughly monotonic, so inserts cluster at the end of the index like an integer would, while keeping enough random bits to stay unguessable. PostgreSQL 18 added a built-in uuidv7() in its September 2025 release for exactly this workload.

If an ID is a primary key and you insert a lot, time-ordered beats random. If it is just a random token in a column with its own index, it matters far less.

Collisions: the math you can mostly ignore

People worry about collisions more than the numbers justify. Collision risk follows the birthday problem: in an N-bit space, you expect a collision after roughly 2^(N/2) values, not 2^N. For UUIDv4's 122 random bits, that is around 2^61 IDs before a coin-flip chance of any duplicate. NanoID puts it concretely — at its default size you would need to generate about 103 trillion IDs to reach a one-in-a-billion chance of a single collision.

The practical takeaways: do not invent your own short IDs by truncating to 8 characters and hoping — entropy is what protects you, and there is not much in 48 random bits. And if you shrink NanoID's length to save space, you are spending collision margin: fine for low-volume keys, risky for high-volume ones. ULID and UUIDv7 spend some bits on the timestamp, so their random portion (80 and 74 bits) is what actually resists collisions. That is still plenty.

The leak: what an ID tells an attacker

Sequential IDs leak two things. First, volume and timing — /orders/1042 tells a competitor you have had about a thousand orders, and watching the number climb reveals your rate. Second, and worse, they make enumeration trivial. If /invoice/1042 is yours, an attacker tries 1041 and 1043. When the server returns someone else's invoice, that is an insecure direct object reference (IDOR), OWASP's canonical example of broken access control.

High-entropy random IDs help here, but be precise about why. A UUIDv4 or NanoID is not practically guessable, so it can act as a capability — an unguessable share link, a password-reset token, a one-time URL where holding the link is the authorization. Several of our tools work this way: the ID is the secret.

Note what UUIDv7 and ULID give up there. Their leading timestamp is not random. If two records were created in the same millisecond, only the random bits differ — and the timestamp itself reveals creation time, which can be sensitive. They resist guessing, but they are not opaque. For a capability URL, prefer a fully random ID.

Random IDs are not access control

This is the mistake worth stating plainly. Swapping sequential IDs for UUIDs does not fix IDOR — it only makes it harder to stumble into by guessing. The OWASP IDOR cheat sheet is explicit: even with complex identifiers, the server must check on every request that the authenticated user is allowed to touch that object. IDs leak — in logs, referrer headers, shared links, browser history. Treat an unpredictable ID as defense in depth, never as the permission check.

Which to pick

  • Database primary key, high insert volume: UUIDv7 or ULID. Time-ordered keeps the index healthy; the random tail keeps it unguessable enough.
  • Public capability URL, share link, or token where the ID is the secret: UUIDv4 or NanoID. Fully random, no timestamp to leak, no order to enumerate.
  • Short, URL-friendly IDs for humans (slugs, codes): NanoID with a chosen length, accepting the collision-margin tradeoff and keeping it off security-sensitive paths.
  • Interop with code that expects the standard 36-character format: a UUID (v4 or v7). ULID and NanoID are not UUIDs.
  • Anything user-facing and sensitive: pick by leak profile, then add a real access-control check regardless of which you picked.

Further reading