UUID is the default unique identifier for most applications. But UUID v4 — random by design — is unordered. Rows inserted into a database aren’t stored in insertion order. Indexes fragment. Queries slow down. ULID was designed to fix exactly this problem.
What Is a ULID?
ULID stands for Universally Unique Lexicographically Sortable Identifier. It was created by Alizain Feerasta in 2016 and is defined in the ULID spec.
A ULID looks like this:
01HQ7V5J3Z2QK8MNRPXTYW9E4B
26 characters, uppercase, using Crockford’s Base32 alphabet (excludes I, L, O, U to avoid visual ambiguity).
The structure
01HQ7V5J3Z 2QK8MNRPXTYW9E4B
|-----------|-----------------|
timestamp randomness
48 bits 80 bits
- Timestamp (48 bits): milliseconds since Unix epoch. The spec says it won’t run out of space until the year 10889 AD (2⁴⁸ − 1 ms is 10889-08-02 UTC).
- Randomness (80 bits): from a cryptographically secure source where possible (spec wording). ~1.2 × 10²⁴ possible values per millisecond.
ULID vs UUID: Key Differences
| Feature | UUID v4 | ULID |
|---|---|---|
| Length | 36 chars (with hyphens) | 26 chars |
| Sortable | No | Yes (lexicographic) |
| Timestamp embedded | No | Yes |
| Collision probability | Extremely low | Extremely low |
| URL-safe | Yes (hex digits and hyphens are unreserved in RFC 3986) | Yes |
| Case-insensitive | Yes | Yes (by spec) |
| Spec | RFC 9562 (obsoletes RFC 4122) | ULID spec |
The critical difference: ULIDs sort by creation time. Insert 1000 rows in a ULID-keyed table and the primary key index stays sequential. This matters for:
- B-tree indexes: Sequential inserts hit the same page, reducing fragmentation.
- Pagination: You can use
WHERE id > :cursoras an efficient cursor without a separatecreated_atcolumn. - Log files: Sorted by filename = sorted by time.
- Message queues: Natural ordering without a separate sequence number.
ULID vs UUID v7
UUID v7 (RFC 9562, published May 2024) also embeds a millisecond timestamp in the first 48 bits — making it sortable. Section 2.1 of the RFC lists ULID among the time-ordered ID schemes that motivated the new versions.
| Feature | ULID | UUID v7 |
|---|---|---|
| Format | 26-char Base32 | 36-char hex with hyphens |
| RFC standard | No | Yes (RFC 9562) |
| DB native type | TEXT or BINARY | UUID type (PostgreSQL) |
| Spec maturity | Stable community spec | New RFC |
If your database has native UUID support (PostgreSQL’s uuid type), UUID v7 may be more convenient — it stores in 16 bytes natively. If you’re working with string-based systems or want shorter IDs, ULID is still the better choice.
Monotonicity Within the Same Millisecond
When multiple ULIDs are generated in the same millisecond, the timestamp portion is identical. The ULID spec defines a monotonicity extension: increment the random component by 1 for each subsequent ULID within the same millisecond. This keeps IDs from one generator in order. If the random component overflows within one millisecond, generation fails. IDs made by different processes in the same millisecond are still ordered only by their random bits.
The ZeroTool generator applies this extension, like monotonicFactory() from the ulid package: within one millisecond each ULID adds 1 to the random part of the one before, so a batch is listed in sorted order, and generation stops with an error if the random part would overflow.
Generating ULIDs in Code
JavaScript / TypeScript
npm install ulid
import { ulid } from 'ulid';
const id = ulid();
// "01HQ7V5J3Z2QK8MNRPXTYW9E4B"
// With a custom timestamp
const id2 = ulid(Date.now());
Go
go get github.com/oklog/ulid/v2
import (
"crypto/rand"
"fmt"
"github.com/oklog/ulid/v2"
)
id := ulid.MustNew(ulid.Now(), rand.Reader)
fmt.Println(id) // "01HQ7V5J3Z2QK8MNRPXTYW9E4B"
ulid.Make() (v2.1+) is shorter and monotonic within a millisecond, but its default entropy is math/rand seeded from the clock, so use crypto/rand when IDs must be hard to guess.
Python
pip install python-ulid
from ulid import ULID
id = ULID()
print(str(id)) # "01HQ7V5J3Z2QK8MNRPXTYW9E4B"
print(id.timestamp) # Unix seconds as a float
print(id.datetime) # timezone-aware datetime (UTC)
Rust
[dependencies]
ulid = "1"
use ulid::Ulid;
let id = Ulid::new();
println!("{}", id); // "01HQ7V5J3Z2QK8MNRPXTYW9E4B"
Storing ULIDs in a Database
PostgreSQL
-- As text (26 bytes); the application generates the ULID
CREATE TABLE events (
id CHAR(26) PRIMARY KEY,
...
);
-- As UUID (16 bytes, loses the string format)
-- Convert in the application, e.g. ulidToUUID() from the ulid npm package
PostgreSQL has no built-in ULID type or generator function (a DEFAULT needs an extension), so most teams generate the ID in the application and store it as TEXT, BYTEA or uuid. Many teams use TEXT for clarity; the 26-byte cost is negligible compared to the query simplicity.
MySQL / SQLite
Same approach — store as CHAR(26) or VARCHAR(26). It’s a fixed-width string, so CHAR(26) is slightly more efficient.
Decoding a ULID
The timestamp is embedded in the first 10 characters. You can extract it:
import { decodeTime } from 'ulid';
const ts = decodeTime('01HQ7V5J3Z2QK8MNRPXTYW9E4B');
console.log(new Date(ts).toISOString()); // 2024-02-22T07:23:36.959Z
This is useful for debugging: you can tell exactly when a record was created from its ID alone, without querying created_at.
When to Use ULID
- Any table with high insert rates where index fragmentation is a concern
- Event logs, audit trails, or any append-heavy workload
- Systems where you need time-ordered IDs without a separate timestamp column
- APIs where you want URL-safe, human-readable IDs shorter than UUID