Javid
·15 min read

ULID: What It Is, How It Sorts, and Where It Breaks

SelfDevKit ID Analyzer, which decodes ULIDs and UUIDs, showing an ID type with Base64 and hex output

What is a ULID?

A ULID (Universally Unique Lexicographically Sortable Identifier) is a 128-bit ID made of a 48-bit Unix timestamp in milliseconds followed by 80 random bits. It is written as 26 Crockford Base32 characters, such as 01ARZ3NDEKTSV4RRFFQ69G5FAV, so sorting ULIDs as plain strings sorts them by creation time.

A ULID gives you what UUID v4 cannot: IDs that sort in the order they were created, with no central counter and no coordination between machines. That one property is why ULIDs show up as primary keys, event IDs, and log correlation IDs.

The format itself takes five minutes to learn. The tricky part is how ULID libraries behave, because they disagree with each other on ordering within a millisecond, on which strings count as valid, and on case. The library behavior and test numbers below come from running the actual libraries: ulid 3.0.2 for JavaScript, python-ulid 4.0.1, the Rust ulid crate (1.2.1 and 3.0.0), and PostgreSQL 18.6, both as a regular server and through PGlite.

The ULID format, character by character

A ULID is 16 bytes. The first 6 bytes hold the timestamp and the last 10 hold randomness, both big-endian, as defined in the ULID spec. In text form the timestamp takes the first 10 characters and the randomness takes the last 16:

 01ARZ3NDEK      TSV4RRFFQ69G5FAV
|----------|    |----------------|
 timestamp        randomness
  48 bits           80 bits

The alphabet is Crockford's Base32: 0123456789ABCDEFGHJKMNPQRSTVWXYZ. It drops I, L, and O, which are easily confused with 1 and 0 when IDs are read aloud or copied by hand, and U to avoid accidental obscenities.

Twenty-six Base32 characters can hold 130 bits, but a ULID only has 128. So the first character can only be 0 through 7, and the largest legal ULID is 7ZZZZZZZZZZZZZZZZZZZZZZZZZ. That maximum timestamp, 2^48 − 1 milliseconds, falls in the year 10889.

Each timestamp character covers a fixed slice of time, which is handy when you are eyeballing IDs in a log:

Position One step equals What it tells you
1 ~1,115 years Always 0 until December 3084
2 ~34.8 years 1 from 2004 to 2039
3 ~398 days Roughly the year. M began on 2026-08-14
4 ~12.4 days Week-ish
5 ~9.3 hours Part of the day
6 ~17.5 minutes
7 ~32.8 seconds
8 ~1.02 seconds
9 32 ms
10 1 ms Exact millisecond

That is why every ULID minted right now starts with 01M. Two IDs that share their first eight characters were created within about a second of each other.

How to decode a ULID timestamp

To decode a ULID's timestamp, read the first 10 characters as a base-32 number using the Crockford alphabet. The result is milliseconds since the Unix epoch. Every mainstream library exposes this directly:

import { decodeTime } from 'ulid';

decodeTime('01ARZ3NDEKTSV4RRFFQ69G5FAV'); // 1469922850259
new Date(1469922850259).toISOString();   // '2016-07-30T23:54:10.259Z'
from ulid import ULID

ULID.from_str('01ARZ3NDEKTSV4RRFFQ69G5FAV').datetime
# datetime.datetime(2016, 7, 30, 23, 54, 10, 259000, tzinfo=datetime.timezone.utc)

With no library at hand, a short bash function does the job. It upper-cases the input first, then finds each character's index in the alphabet. The ${1^^} expansion needs bash 4 or newer, so on macOS, which still ships bash 3.2 as /bin/bash, run it with a newer bash:

ulid_ts() {
  local s=${1^^} a=0123456789ABCDEFGHJKMNPQRSTVWXYZ t=0 c p i
  for ((i = 0; i < 10; i++)); do
    c=${s:i:1}; p=${a%%"$c"*}; t=$((t * 32 + ${#p}))
  done
  echo "$t"
}

ulid_ts 01ARZ3NDEKTSV4RRFFQ69G5FAV               # 1469922850259
date -u -d @$(( $(ulid_ts 01ARZ3NDEKTSV4RRFFQ69G5FAV) / 1000 )) +%FT%TZ
# 2016-07-30T23:54:10Z

That date -d @... form is GNU date. The BSD date on macOS takes the seconds with -r instead: date -u -r 1469922850 +%FT%TZ.

Once you have the milliseconds, it is an ordinary epoch value. The guide to converting Unix timestamps covers turning it into a date inside SQL and log pipelines, and SelfDevKit's timestamp converter does it with automatic seconds vs milliseconds detection.

Monotonic ULIDs: ordering inside one millisecond

A ULID sorts by time only down to the millisecond. Two IDs created in the same millisecond compare by their random part, so their order is a coin flip, and the spec says plainly that within the same millisecond, sort order is not guaranteed.

The spec also defines an optional fix called monotonic generation. When a new ID lands in the same millisecond as the previous one, the generator reuses the previous random part and adds 1 instead of drawing fresh randomness. Whether you get this behavior depends entirely on which function you call. In a test run of 100,000 IDs generated in a tight loop with each library's default and monotonic APIs, counting how often an ID sorted before the one generated just ahead of it, the results were (exact counts vary from run to run):

Library and call Same-ms pairs Out of order Monotonic by default?
JS ulid() 95,937 48,103 (50%) No
JS monotonicFactory()() 99,932 0 Opt-in
Python ULID() (python-ulid 4.0.1) 98,760 0 Yes, StrictMonotonicPolicy
Rust Ulid::new() (crate 1.2.1; renamed Ulid::generate() in 3.0.0) 99,987 50,038 (50%) No
Rust ulid::Generator 99,988 0 Opt-in

So the same code pattern, "generate IDs in a loop and rely on ORDER BY id", is correct in Python and silently wrong half the time in JavaScript and Rust. If insertion order matters, as with an event log or a cursor-paginated feed, use the monotonic API explicitly and do not rely on a default.

Monotonic generation has two costs:

  1. Predictability. Consecutive IDs from one generator in the same millisecond differ by exactly 1, for example 01M4PT70ZRBZP8J3XA6CGM1DV5 followed by 01M4PT70ZRBZP8J3XA6CGM1DV6. Anyone holding one ID can guess its neighbors.
  2. Overflow. If the random part reaches its maximum inside a single millisecond, a strict generator throws. With 80 random bits that is extremely unlikely, but python-ulid also ships a LaxMonotonicPolicy that draws fresh randomness instead of raising, if you would rather lose ordering than crash.

Monotonicity is also per generator. Two servers, or two JS workers each with their own factory, still interleave randomly within a millisecond, and clock drift between machines can reorder IDs across milliseconds too. A ULID gives you approximate global order, not a total order.

Which ULID strings are valid? The libraries disagree

A valid ULID is exactly 26 characters from the Crockford alphabet, with a first character from 0 to 7. Crockford's Base32 also says decoders should accept lowercase and treat I and L as 1 and O as 0. The ULID spec lists "case insensitive" as a feature and says any value above 7ZZZZZZZZZZZZZZZZZZZZZZZZZ should be rejected by all implementations. Here is what each library actually does with the same eight inputs:

Input JS isValid() JS decodeTime() Python from_str() Rust from_string()
Uppercase, valid true 2016-07-30 OK OK
Same ID, lowercase true 2016-07-30 ValueError OK
O and L instead of 0 and 1 false throws ValueError InvalidChar
Contains U false 2016-07-30 ValueError InvalidChar
7ZZZZZZZZZZZZZZZZZZZZZZZZZ (max) true year 10889 OK OK
80000000000000000000000000 (overflow) true throws ValueError OK, becomes all zeros
25 characters false throws ValueError InvalidLength
27 characters false throws ValueError InvalidLength

Three results stand out:

  • python-ulid 4.0.1 rejects lowercase ULIDs. An ID that a JavaScript or Rust service lowercased will fail validation in a Python service.
  • JavaScript's isValid() says the overflow value is valid, while decodeTime() on the same string throws. decodeTime() also decodes a string ending in U that isValid() rejects, because it only reads the first 10 characters. Passing decodeTime() is not validation.
  • The Rust crate accepts the overflow value and silently wraps it to 00000000000000000000000000, timestamp 0. The latest release, 3.0.0, still does this. Nothing errors; you simply get a ULID from 1970.

None of the three libraries applies Crockford's I/L/O substitution.

The practical fix is to validate at your system boundary with one explicit rule and normalize case before anything else touches the ID:

const ULID_RE = /^[0-7][0-9A-HJKMNP-TV-Z]{25}$/;

function parseUlid(input) {
  const id = input.trim().toUpperCase();
  if (!ULID_RE.test(id)) throw new Error(`Invalid ULID: ${input}`);
  return id;
}

That regex rejects every bad row in the table, including the overflow case, and accepts the lowercase ID once it has been upper-cased. If you want to try variations of it against your own sample IDs, SelfDevKit's regex tester runs locally, and the regex cheat sheet covers how character-class ranges port between engines.

Sorting ULIDs as strings: the case trap

ULIDs sort correctly as strings only when every ID uses the same case. In ASCII, every uppercase letter sorts before every lowercase letter, so a lowercase ULID sorts after all uppercase ones regardless of its timestamp.

In a test, 2,000 time-ordered ULIDs went into a PostgreSQL text column, one second apart, with every second one lowercased. ORDER BY id COLLATE "C" returned 1,998 of the 2,000 rows in the wrong position: all 1,000 uppercase IDs first, then all 1,000 lowercase ones. A two-row version shows the same thing:

SELECT x FROM (VALUES
  ('01m4qne6m09ve2ar0wa7f498w9'),  -- 09:00:00, lowercase
  ('01M4QNE7K8BZ1GXK1FGDHXHAMT')   -- 09:00:01, uppercase
) v(x) ORDER BY x COLLATE "C";

-- 01M4QNE7K8BZ1GXK1FGDHXHAMT   <- newer ID sorts first
-- 01m4qne6m09ve2ar0wa7f498w9

Mixed case creeps in easily. One service lowercases IDs for URLs, another stores what it received, and a migration copies both into the same column. Nothing fails. Pagination just starts skipping and repeating rows. Normalize to uppercase on write, add a CHECK (id = upper(id)) constraint if the column is text, and declare it COLLATE "C" so comparisons are plain byte order and do not depend on the server's locale.

Storing ULIDs in a database: text or uuid?

A ULID is 128 bits, the same size as a UUID, so you can store its raw bytes in a native uuid column. The conversion just re-encodes the same 16 bytes as hex:

import { ulidToUUID, uuidToULID } from 'ulid';

ulidToUUID('01ARZ3NDEKTSV4RRFFQ69G5FAV');
// '01563E3A-B5D3-D676-4C61-EFB99302BD5B'
uuidToULID('01563E3A-B5D3-D676-4C61-EFB99302BD5B');
// '01ARZ3NDEKTSV4RRFFQ69G5FAV'

PostgreSQL compares uuid values byte by byte, so time order survives the conversion. In the test above, the uuid column sorted all 2,000 rows correctly with zero inversions, and case stops being an issue because hex input is case-insensitive.

It is also smaller. In a test with 100,000 ULIDs loaded as a text primary key and the same IDs as a uuid primary key (PostgreSQL 18.6, rows inserted in ID order, sizes after VACUUM ANALYZE):

Storage Bytes per value Table size Primary key index
text 27 6.07 MB 5.00 MB
uuid 16 4.46 MB 3.18 MB

The uuid index came out about 36% smaller in that test; the exact figures depend on row width, fill factor, and insert order. Insert order matters more than it looks. Loading the same 100,000 IDs in the order a non-monotonic ulid() loop produced them, which is shuffled within each millisecond, left the text index at 7.00 MB and the uuid index at 3.24 MB, because inserts that land behind the newest key split pages in half. On MySQL the equivalent choice is BINARY(16) instead of CHAR(26).

Two caveats come with the uuid route.

It is not a valid UUID v7. A ULID has no version or variant bits, so those positions hold random data. In 01563E3A-B5D3-D676-4C61-… the version nibble is D, which RFC 9562 has not assigned, and the variant bits fall in the range the RFC reserves for legacy NCS UUIDs. PostgreSQL stores it fine, but its own functions do not recognize it: uuid_extract_version() and uuid_extract_timestamp() both returned NULL for this value. Any validator that insists on a real v4 or v7 will reject it.

"Compliant" conversions lose data. Some libraries can force the version and variant bits so the result passes as a real UUID. In python-ulid, to_uuid4() does this, and converting back gives 01ARZ3NDEK8SV8RRFFQ69G5FAV instead of the original 01ARZ3NDEKTSV4RRFFQ69G5FAV. Six bits were overwritten. Use the plain byte conversion if you ever need the original string back.

Query by time range without a created_at column

Because the timestamp is the prefix, a time window maps to a key range. Encode the window boundaries as ULID prefixes and pad the randomness with zeros:

import { encodeTime } from 'ulid';

const from = encodeTime(Date.UTC(2026, 9, 12), 10) + '0'.repeat(16);
const to   = encodeTime(Date.UTC(2026, 9, 13), 10) + '0'.repeat(16);
// from = '01M4PPHE000000000000000000'
SELECT count(*) FROM orders WHERE id >= $1 AND id < $2;

Against a test table holding 48 hours of orders, this returned exactly the 240 rows from October 12. Because the predicate is a plain range on the primary key, PostgreSQL can answer it with an index scan; with a text key, declare the column COLLATE "C" so the index order matches byte order. For a uuid column, pass the boundaries through ulidToUUID() first.

What a ULID reveals about your data

Every ULID publishes its creation time to the millisecond. Put one in a URL and anyone can decode when that account, order, or document was created, and by watching IDs over time they can estimate your volume. That is fine for internal keys and often not fine for public ones.

A ULID is also not a secret. Its 80 random bits make collisions practically impossible, but a monotonic generator makes the next ID trivially guessable, so never use one as a password-reset token, session ID, or API key. Those need a full-entropy random value. The API key generator guide covers what that looks like, and SelfDevKit's secret generator produces them locally.

ULID vs UUID v7: which should you use?

ULID and UUID v7 solve the same problem in nearly the same way: both put a 48-bit millisecond Unix timestamp in the high bits so IDs sort by creation time. The difference is mostly packaging.

ULID UUID v7
Specification Community spec on GitHub RFC 9562 (IETF, 2024)
Text form 26 chars, Base32 36 chars, hex with hyphens
Random bits 80 74 (6 bits go to version and variant)
Native DB type No, store as text or convert to uuid Yes, uuid
Database generation Extensions or app code Built into PostgreSQL 18 as uuidv7()
Same-ms ordering Optional monotonic mode, varies by library Optional counter in rand_a, varies by implementation
Human-friendly Short, no hyphens, double-click selects it Long, hyphens break word selection

If your database has a native uuid type and you are starting fresh, UUID v7 is the lower-friction choice: standard tooling recognizes it, and PostgreSQL 18 can generate it for you. ULID remains a good pick when the ID is something people see and copy, such as URLs, support tickets, or log lines, or when your stack already speaks ULID. For the wider field of options, including v4, NanoID, and KSUID, see the UUID generator guide.

Generating and inspecting ULIDs offline

SelfDevKit's ID Generator creates ULIDs alongside UUIDs (v1, v4, v7), GUIDs, NanoIDs, and KSUIDs, in batches, without a network connection. One honest note based on the table above: each ID in a batch is generated independently, not with a monotonic generator, so IDs that land in the same millisecond are not ordered among themselves. That is fine for fixtures and seed data. If you need a strictly ordered sequence, use a monotonic API in code.

SelfDevKit ID Generator, which creates ULIDs and UUIDs in batches offline, with five generated IDs

The ID Analyzer works in the other direction. Paste a ULID from a log line and it shows the embedded timestamp, the random part, and the raw 16 bytes as hex and Base64. The hex value is the same 16 bytes you would store in a uuid column, since PostgreSQL accepts it without hyphens. It also recognizes UUIDs and KSUIDs, which helps when you are not sure which kind of ID you are looking at. Treat it as a decoder, not a validator: check the format with the regex above first.

SelfDevKit ID Analyzer, which also decodes ULIDs, showing the type and byte representations of a UUID v7

Both tools run on your machine, so production IDs, which can reveal customer creation times, never get pasted into a web page.

Download SelfDevKit to generate and decode ULIDs, UUIDs, and 50+ other developer formats offline on macOS, Windows, and Linux.

Related Articles

UUID Generator: How to Create Unique IDs (v4, v7, ULID, and More)
DEVELOPER TOOLS

UUID Generator: How to Create Unique IDs (v4, v7, ULID, and More)

Generate UUIDs and unique IDs for your projects. Learn which version to use, how they work, and why UUID v7 is replacing v4.

Read →
Convert Unix Timestamp: Identify the Unit and Epoch First
DEVELOPER TOOLS

Convert Unix Timestamp: Identify the Unit and Epoch First

Convert Unix timestamp values correctly by identifying the unit and epoch first, then converting inside SQL, log tools, and spreadsheets.

Read →
GUID Generator Online: Formats, Code, and a Private Alternative
DEVELOPER TOOLS

GUID Generator Online: Formats, Code, and a Private Alternative

Generate GUIDs online, learn the .NET format specifiers, and see why an offline GUID generator keeps your identifiers off unknown servers.

Read →