Start with the classic key-selection dilemma
When picking a primary key for a message or order table you will eventually face this choice:
UUID v4 : 550e8400-e29b-41d4-a716-446655440000 (random, 36 characters)
ULID : 01HZ3G7DZP5XA5Z4G5TQXW2Q7T (sortable, 26 characters)
Both are globally unique and can be generated offline, but one sorting test exposes the split: order 100 rows by UUID and then shuffle them by creation time—the sort order and real order are unrelated. Switch to ULID and lexicographic order equals creation order.
That single difference drives how they behave in database indexes, log aggregation and debuggability.
How ULID encodes time into its prefix
ULID is just 48 bits of timestamp + 80 bits of randomness:
48 bit 80 bit
timestamp randomness
────────── ───────────────────────────── ──
01HZ3G7DZP 5XA5Z4G5TQXW2Q7T
- The first 48 bits are milliseconds since the Unix epoch, encoded as 10 Base32 characters;
- The remaining 80 bits are cryptographically random bytes, encoded as 16 characters;
- Total is 26 characters from the Crockford Base32 alphabet (excluding
I/L/O/U).
To decode the timestamp, reverse-encode the first 10 characters. This is the root of sortability—compare time first, and only the randomness when times are equal.
Comparing the three mainstream identifier families
| Property | UUID v4 | ULID | Snowflake-like |
|---|---|---|---|
| Length | 36 chars | 26 chars | ~19 decimal digits |
| Time-sortable | No | Yes (ms) | Yes (second + sequence) |
| Offline generation | Fully offline | Fully offline | Needs node/coordinator |
| Strict cross-node order | No | No (clock-drift bound) | Implementation-dependent |
| Readability | Medium (dashed groups) | High (compact) | High (plain digits) |
The Snowflake family trades offline generation (it needs a worker ID) to be shorter or to align with a single datacenter; ULID generates offline as a pure function in most languages, making it the simplest to deploy.
Keeping it monotonic within a millisecond
Many implementations add a monotonic mode: when generated again in the same millisecond, the random bits act as an incrementing counter until they overflow or the next millisecond arrives. This matters for sequential inserts—out-of-order rows in the same millisecond would still cause page splits.
// JavaScript sketch: increment the random bits within the same millisecond
let lastGen = null, lastRand = 0n
function ulid() {
const now = Date.now()
if (now === lastGen) lastRand++ // monotonic
else { lastGen = now; lastRand = 0n }
return encode(now, lastRand)
}
Why ordered primary keys matter to databases
Cloudy-indexed engines (like InnoDB) treat the primary key as physical order. A random key inserts a new row in the middle of a page, causing page splits, random IO and cache misses; an ordered key always appends at the page tail. At tens of millions of rows the difference in write throughput and cache hit ratio becomes dramatic.
That is why, when you generate a key with a ULID generator, you are usually after "globally unique plus roughly chronological"—often a better database-friendly default than UUID v4.
One trade-off to see clearly: privacy
Because ULID embeds its timestamp, anyone who holds the ID knows when it was created. That is both a debugging lifesaver and a possible leak toward the user. Good practice:
- For user-facing public identifiers (order IDs, invite codes) where you must hide time, prefer UUID v4;
- For server-internal keys, database PKs, logs and event IDs, prefer ULID;
- For compliance-sensitive creation time, store the time field encrypted separately rather than leaking it through a sortable ID.
Suggested selection rule
Choose ULID when: you want ordered writes to a relational PK, logs that aggregate by time, simple offline generation, and a shorter value.
Choose UUID v4 when: the value is exposed to end users, you strictly don't want to leak time, or you need standard interop with external systems.
Choose Snowflake/DB sequences when: you hard-need strict global ordering across nodes and can supply a worker ID across datacenters.
Try it yourself
Generate a batch of ULIDs, sort them lexicographically and compare with the encoding—they will line up in creation order. Re-run the same with UUID v4 and the order will scramble. That experiment makes the properties stick.