Skip to main content

The ULID Guide: A Sortable, URL-Safe Alternative to the UUID

How a ULID packs a 48-bit timestamp and 80 random bits into 26 Crockford base32 characters, why it sorts by time, and how it stacks up against UUID and UUIDv7.

Published By Li Lei
#ulid #unique-id #database #backend #crockford-base32

The ULID Guide: A Sortable, URL-Safe Alternative to the UUID

I have lost count of how many times I have added a created_at column purely so I could write ORDER BY created_at DESC. The primary key was a random UUID, which told me nothing about when a row was born, so I bolted on a timestamp and indexed it too. A ULID makes that second column optional. The identifier itself carries the time, and a plain string sort of your IDs is also a sort by creation order. That one property reshapes how you think about keys.

ULID stands for Universally Unique Lexicographically Sortable Identifier. It is 128 bits of payload, the same size as a UUID, but it splits those bits into a time half and a random half and encodes the result so that older IDs always come first.

What a ULID actually looks like

A ULID is 26 characters of Crockford base32. The structure is fixed and worth memorizing: the first 10 characters hold a 48-bit millisecond timestamp, and the last 16 characters hold 80 random bits. Add them up — 48 plus 80 equals 128 — and you have a UUID-sized identifier in a more compact, dash-free string.

Here is a concrete one:

01HQ3R9X8M4ZJ7W2YB5T6KQP0F
└────────┘└──────────────┘
 timestamp     randomness
 (10 chars)    (16 chars)

The leading 01HQ3R9X8M decodes to roughly 1707000000000 milliseconds since the Unix epoch, which lands in early February 2024. Paste a real ULID into the decoder on the ULID Generator and it reads that timestamp straight back out as a millisecond count plus a human-readable UTC time. No database round-trip, no schema access — the creation time is baked into the first ten characters.

One nice side effect of the math: the 48-bit timestamp lives in 50 bits of base32 (10 characters times 5 bits each), so the very first character only ever uses 3 of its 5 bits. That is why a valid ULID never starts with a character above 7. If you see an ID beginning with 8 or higher, it is malformed.

Why a plain string sort works

The trick is that the timestamp is stored big-endian — most significant bits first — and base32 preserves that ordering character by character. When you compare two ULID strings left to right, you are effectively comparing their timestamps left to right. An ID minted at 9:00:00.000 sorts before one minted at 9:00:00.001 because their leading characters differ in the expected direction.

So ORDER BY id and ORDER BY created_at return the same sequence. For a B-tree index this matters in a second way: new rows append near the right edge of the index instead of scattering across random pages the way UUIDv4 inserts do. Your "give me the 50 newest rows" query becomes ORDER BY id DESC LIMIT 50, and your index stays tidy under heavy inserts.

The 80 random characters only do one job: break ties. If two IDs share the same millisecond, their timestamps are identical and the random tail decides their order. By default that tail is fresh randomness, so the order between same-millisecond IDs is arbitrary — which is where monotonic mode comes in.

Crockford base32, and why I-L-O-U are missing

ULIDs use Crockford's base32 alphabet, not the standard RFC 4648 one. The alphabet is 0123456789ABCDEFGHJKMNPQRSTVWXYZ — 32 symbols, 5 bits each. The letters I, L, O, and U are deliberately excluded. I and L are dropped because they look like 1, O because it looks like 0, and U because removing it sidesteps accidental profanity in generated strings.

This is a small detail that bites people who validate ULIDs by hand. A regex like [0-9A-Z]{26} accepts the full A–Z range and will happily pass a string containing an I or an O, neither of which a real encoder ever produces. If you validate, validate against the exact 32-character set. A round trip through the Base32 / Base58 Encoder is a quick way to see how Crockford's variant differs from the standard alphabet when you are debugging an encoding mismatch.

ULID vs UUID vs UUIDv7

These three get compared constantly, so here is the honest breakdown.

UUIDv4 is 122 bits of pure randomness wrapped in a 128-bit layout, rendered as 36 characters with hyphens. It is the safe default everyone reaches for. Its weakness is exactly what a ULID fixes: two UUIDv4 values created a second apart have no relationship, so you cannot sort by them and you pay an index-fragmentation cost on inserts.

ULID keeps the 128-bit size but front-loads a millisecond timestamp, encodes in 26 dash-free Crockford characters, and sorts by time. It is shorter on the wire than a UUID and URL-safe with no escaping. The trade-off is that it leaks creation time and is not an official IETF standard — it is a community spec.

UUIDv7 is the newer kid. It is a real IETF standard (RFC 9562, 2024) that does essentially the same thing as a ULID: a 48-bit millisecond timestamp in the high bits, then random bits, sorted by time. The difference is mostly presentation. UUIDv7 renders as a standard 36-character hyphenated UUID, so it drops straight into any column, ORM, or library that already expects a uuid type, and it uses hex rather than base32. A ULID is shorter and prettier in a URL; UUIDv7 is more compatible with existing UUID infrastructure.

My rule of thumb: if you control the schema and want compact, readable, URL-friendly IDs, reach for ULID. If you are slotting into a system that already speaks UUID and you just want the time-sortable upgrade, UUIDv7 is the lower-friction choice. If you only need uniqueness and never sort by the key, plain UUIDv4 from the UUID Generator is still perfectly fine.

Monotonic mode and the gotchas

Within a single millisecond, default ULID generation gives random ordering between IDs. Monotonic mode fixes this: it reuses the current timestamp and increments the random component by one for each new ID, so id[1] > id[0] always holds. Turn it on for append-only event logs, outbox tables, or any place where you mint many rows per millisecond and rely on the ID for ordering. Forget it during a burst and your "sort by ID" guarantee quietly breaks.

Two more traps worth flagging. First, a ULID is not a secret. The 80 random bits make collisions effectively impossible, but the timestamp half is fully readable by anyone. Never use a ULID as a session token, a password-reset code, or anything that must be unguessable in full — for that you want a Password Generator and a properly random secret. Second, exposing ULIDs in public URLs leaks roughly when each record was created, and a run of monotonic ULIDs hints at how fast you are creating records. If creation timing is sensitive, keep the IDs internal.

That is the whole model: 48 bits of time, 80 bits of randomness, 26 Crockford characters, sorted for free. Generate a batch, decode one to confirm the timestamp, and decide whether ULID or UUIDv7 fits your stack better.


Made by Toolora · Updated 2026-06-13