ULID Sortable Identifiers

Protocols & Interoperability intermediate Building with AI ~30 min read

Overview

Distributed systems need identifiers that can be generated without a central coordinator, survive network partitions, and remain convenient for APIs, logs, databases, and object storage paths. Random UUIDs solve decentralization but are awkward for ordered storage: newly inserted rows scatter across indexes, log streams do not sort naturally by creation time, and humans have to handle long strings with hyphens.

ULID, the Universally Unique Lexicographically Sortable Identifier, is a 128-bit identifier format that combines a millisecond timestamp with random entropy and encodes the result as a 26-character Crockford Base32 string. Its first characters represent time, so ordinary lexicographic sorting produces chronological order across different milliseconds. Its random suffix keeps generation decentralized and collision-resistant.

The format is useful when an ID has to cross many boundaries: JSON payloads, URLs, database primary keys, logs, queue messages, and filenames. It is not a universal replacement for every UUID variant, but it is a strong default for application-generated resource IDs where creation order and storage locality matter.

Core Concepts

Timestamp Prefix

The first 48 bits encode Unix time in milliseconds. In the canonical text representation, those bits occupy the first 10 Base32 characters. Because the most significant timestamp bits appear first, bytewise string sorting groups identifiers by creation time without a separate created-at column in the sort key.

Randomness Suffix

The remaining 80 bits are random. This entropy lets many processes generate IDs independently in the same millisecond without contacting a coordinator. The randomness also prevents the timestamp from being the whole identifier; two resources created at the same millisecond still receive different IDs with extremely high probability.

Crockford Base32 Encoding

ULIDs use a Base32 alphabet designed for readability and safe transport. It avoids ambiguous letters and avoids URL-hostile punctuation. A canonical ULID is shorter than the familiar hyphenated UUID string while still representing 128 bits of identifier data.

Key Concept: Lexicographic Time Order

ULID is ordered because the timestamp is encoded first in big-endian order. A datastore, object store, or log processor can sort the string value and get chronological ordering for IDs generated in different milliseconds, without decoding the identifier.

How It Works

Bit Layout

ULID keeps UUID-compatible width: 128 bits total. The high 48 bits are the timestamp, and the low 80 bits are randomness. In binary form, the fields are encoded most-significant-byte first. In string form, the timestamp becomes a 10-character prefix and the random portion becomes a 16-character suffix.

# 128-bit ULID layout
# 48 bits: Unix milliseconds
# 80 bits: randomness

timestamp_ms = now_unix_milliseconds() & ((1 << 48) - 1)
random_bits = secure_random_80_bits()
id128 = (timestamp_ms << 80) | random_bits
text = crockford_base32_encode_26_chars(id128)

Monotonic Generation

Plain random ULIDs sort by millisecond, but IDs created in the same millisecond do not have a guaranteed order. A monotonic factory fixes that within a single process: when it detects the same millisecond as the previous call, it increments the random suffix instead of drawing a new unordered random value. If the suffix overflows, generation must fail rather than wrap.

class MonotonicULID:
    def __init__(self, rng):
        self.last_ms = -1
        self.last_random = 0
        self.rng = rng

    def next(self, now_ms):
        if now_ms > self.last_ms:
            self.last_ms = now_ms
            self.last_random = self.rng.bits(80)
        else:
            self.last_random += 1
            if self.last_random >= (1 << 80):
                raise OverflowError("ULID random suffix exhausted")
        return encode_ulid(self.last_ms, self.last_random)

Parsing and Validation

A parser should reject invalid alphabet characters, invalid lengths, and encoded values larger than 128 bits. Because 26 Base32 characters can represent 130 bits, not every 26-character string is a valid ULID. Strict validation prevents overflow bugs and keeps implementations interoperable.

Practical Applications

Database Primary Keys

ULIDs are attractive for application-assigned primary keys when records are usually inserted near creation order. Compared with purely random UUID v4 values, sorted ULID inserts can reduce index churn and make range scans over recent records simpler. They also let the application create IDs before a database round trip.

Event Logs and Queues

Event IDs often need to be unique, printable, and roughly time ordered. ULIDs work well as event identifiers because they sort by creation time and are safe in JSON, URLs, and queue messages. Consumers can use the ID as an idempotency key while retaining a useful ordering signal.

Object Storage and API Resources

Resource paths such as /accounts/{account_id}/documents/{document_id} benefit from compact URL-safe IDs. ULIDs avoid punctuation, remain case-insensitive in the canonical alphabet, and can be pasted through logs and support tools more easily than binary identifiers.

Implementation Patterns

Store Text or Binary Intentionally

Store ULIDs as text when interoperability, debugging, and URL usage matter most. Store them as 16-byte binary values when index size and comparison speed matter most. Both choices can be correct, but mixing encodings across services creates subtle bugs. Define one wire format and one database format per boundary.

CREATE TABLE documents (
    id CHAR(26) PRIMARY KEY,
    tenant_id CHAR(26) NOT NULL,
    title TEXT NOT NULL,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX documents_tenant_recent
ON documents (tenant_id, id DESC);

Use a Monotonic Generator Per Process

If the application emits many IDs per millisecond and depends on local sort order, wrap the library generator in a process-local monotonic factory. Do not try to coordinate monotonicity globally across every machine unless you actually need a total order. ULID gives practical time locality, not a distributed consensus timestamp.

Keep Creation Time as a Separate Column When Needed

A ULID contains a timestamp, but that does not replace explicit audit fields. Keep created_at when users filter by time, when clocks can be corrected, when imports preserve historical creation times, or when privacy policy requires controlling timestamp exposure. Treat the ULID timestamp as an ordering property, not your only time source.

Performance Considerations

Common Pitfalls

Further Reading