Understanding ULID and NanoID
UUIDs, but for the database we actually use.
Why UUIDv4 is bad for B-tree indexes, what ULID and NanoID change, and the tradeoff between sortability and uniformity.
The UUIDv4 problem.
UUIDv4 is purely random — every byte unrelated to every other. Great for collision resistance, terrible for B-tree indexes: each new row gets a key that lands in a random page of the index, forcing the database to load and rewrite pages all over the disk. On large tables this destroys insert performance. The alternatives are sortable IDs that pack a timestamp into the high-order bits so new rows always append to the end of the index.
ULID — sortable + random.
A ULID is 128 bits: 48 bits of timestamp (milliseconds since 1970), 80 bits of random. Encoded as 26 Crockford-base32 characters — no I/L/O/U, case-insensitive, human-keyboardable. Sorting ULIDs lexically is the same as sorting by creation time within a millisecond; same-millisecond IDs sort by their random tail. The insert pattern is "append at the end of the index", which is what databases like.
ULID = 48-bit timestamp · 80-bit random
NanoID — small + uniform.
NanoID is purely random but optimised for compactness and URL safety. Default 21 characters from a 64-character alphabet — same collision space as UUIDv4 but shorter and URL-safe without quoting. It doesn't carry a timestamp, so it doesn't help with index locality, but it's perfect for slugs, share tokens, public IDs where you want something compact that isn't guessable.
A worked comparison.
UUIDv4 (36 char): f47ac10b-58cc-4372-a567-0e02b2c3d479
ULID (26 char): 01HXY7QRWZGKMP3D5KFNQTV8E2
NanoID (21 char): V1StGXR8_Z5jdHi6B-myT
Same uniqueness guarantee, three different storage shapes. ULID's first 10 characters are a sortable timestamp prefix; NanoID is shorter and URL-safe; UUIDv4 is the universal-but-suboptimal default.
When to pick which
time-ordered insert vs short share token
ULID for primary keys; NanoID for URL slugs.
postgres PK → ULID ; share URL → NanoID
= Pick by use case
UUIDv7, the standardised middle.
UUIDv7 (RFC 9562, 2024) is the standardised successor: same time-prefix idea as ULID, but packaged as a UUID (36 characters, hyphenated, drop-in for existing UUID columns). Most modern databases and libraries now have UUIDv7 support. If you can adopt it, UUIDv7 is the cleanest answer for new primary keys; ULID is the equivalent in a different encoding.
Information leakage.
A time-ordered ID encodes "when was this record created" — sometimes desirable, sometimes not. An attacker who sees two of your IDs can derive their order; sees one of yours and one from earlier and they can estimate your creation rate. For internal IDs, fine. For public-facing IDs, NanoID (or a separately-issued public token) hides that information.
Collision math.
A 21-character NanoID with a 64-character alphabet has 64²¹ ≈ 2¹²⁶ possible values. A ULID has 80 bits of randomness per millisecond. Both make collision-by-chance a non-issue at any realistic scale — you'd need to generate 10¹⁹ NanoIDs before the probability of a collision rose above one in a billion. The longer alphabet variant of NanoID is rarely worth using; the default size is plenty.