Back to all posts

Your Sortable IDs Might Not Sort

Most base62 implementations order their alphabet a-zA-Z0-9. In ASCII that isn't ascending — so a lexicographic sort silently stops matching chronological order, with no error. That bug sent me down a rabbit hole through ULID, UUIDv7, TypeID and nanoid, and eventually into writing my own ID library. Here's what I learned, including the parts where the alternatives are the better choice.

Syed Suhail Ahmed

Syed Suhail Ahmed

Aug 15, 202613 min read

Your Sortable IDs Might Not Sort

Here's a bug you can check for in about ten seconds.

If you generate time-sortable IDs in base62, find the alphabet string your library uses. If it looks like this:

"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

your IDs don't sort chronologically. Not "sometimes." Ever, across a character-class boundary.

The reason is that lexicographic string comparison uses code points, and in ASCII the digits (48–57) come before uppercase (65–90), which comes before lowercase (97–122). An alphabet ordered a…zA…Z0…9 maps the digit 0 — the smallest value in your encoding — to the largest code point. So an earlier timestamp can sort after a later one, silently, with nothing thrown and no test failing unless someone happened to write one that straddles the boundary.

const ascending  = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz";
const common     = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

const isSortSafe = (a) =>
  [...a].every((c, i) => i === 0 || c.charCodeAt(0) > a.charCodeAt(i - 1));

isSortSafe(ascending); // true
isSortSafe(common);    // false  ← lexicographic order ≠ encoded order

I found this while building a small ID library, and it turned out to be the most interesting thing I learned — because it's an invariant that every sortable-ID scheme depends on and almost nobody states out loud.

This post is about that invariant and two other design decisions that surprised me, plus an honest account of where the established options beat the thing I built — including one place where my own library still gets this wrong.

Why the big specs never hit this

ULID and TypeID both encode in Crockford's Base320123456789ABCDEFGHJKMNPQRSTVWXYZ (or lowercased). That alphabet is already in ascending code-point order, so the invariant holds for free and the specs never have to mention it.

Which is fine until you want base62 for density. Base62 has no canonical ordering. Every implementation picks one, and the intuitive pick — "letters then digits," or "lowercase, uppercase, digits" — is exactly wrong. The invariant is real, load-bearing, and undocumented.

So the first design decision: assert it at construction time.

for (let i = 1; i < alphabet.length; i++) {
  if (alphabet.charCodeAt(i) <= alphabet.charCodeAt(i - 1)) {
    throw new RangeError(
      "a sortable `alphabet` must have characters in strictly ascending " +
      "code-point order (no duplicates), otherwise a lexicographic sort " +
      "would not match chronological order."
    );
  }
}

Nine lines, thrown once at setup rather than discovered a year later when someone notices the feed is subtly out of order. Failing loudly at configuration time beats being wrong quietly at runtime — which is the whole theme of this post.

Monotonicity is about clocks, not milliseconds

Every sortable-ID library advertises "monotonic." I assumed that meant "handles two IDs generated in the same millisecond." That's the easy half.

The hard half is the system clock going backwards — NTP correction, VM migration, a leap-second smear, a laptop waking up. When that happens, a naive time-prefixed ID scheme emits an ID that sorts before one you already issued, and if you're using those IDs as pagination cursors or database keys, you now have rows that are invisible to a "newer than X" query.

Here's what the ULID spec says about clock regression: nothing. The word "clock" appears zero times in it. Every implementation invents its own behaviour, and they differ.

ULID also mandates increment-by-1 for its monotonic mode, which makes the next ID trivially predictable from the current one — something RFC 9562 (the UUIDv7 spec) explicitly warns against.

So the second decision: treat clock regression as a first-class case with the same code path as the same-millisecond case.

if (monotonic && time <= lastTime) {   // note: <=, not ===
  time = lastTime;                     // pin to the last time we issued
  const next = incrementIndices(lastRandom, radix);
  if (next) {
    lastRandom = next;                 // bump the random tail
  } else {
    time = lastTime + 1;               // tail exhausted: borrow a millisecond
    lastRandom = randomIndices(radix, randomSize);
  }
  lastTime = time;
}

Three behaviours fall out of that:

  1. Same millisecond → increment the random tail (the ULID approach).

  2. Tail exhausted → borrow a millisecond from the future rather than throwing. With a 16-character base62 tail that's ~95 bits of headroom, so this is theoretical, but "theoretical" is where outages live.

  3. Clock steps backwards → pin to the last issued time and keep incrementing. IDs stay strictly increasing regardless of what the clock does.

Now the costs, because there are three and they all matter.

The state is per generator instance — closure state inside one createSortableId(). Not per-process: two instances in the same process are already unordered relative to each other, and across workers or machines there's no ordering at all. Nothing without coordination can fix that, and any library claiming otherwise is claiming something it can't deliver.

The second cost is subtler, and I didn't appreciate it until I went back over the code for this post. Preserving order under a backwards clock means sacrificing the timestamp. When time is pinned to lastTime, every subsequent ID reports that stale reading — with no cap and no warning. Simulate a three-year NTP step backwards and the IDs stay perfectly ordered while getTimestamp() reports a time three years in the future, indefinitely. Counter exhaustion has the mirror problem: time = lastTime + 1 fabricates a millisecond that never happened.

So sortableId gives you a sort key that happens to be readable as a time, not a timestamp you should trust for anything. If you need to know when a row was created, store a created_at column. ULID's reference implementation behaves the same way under regression, so this is a shared property of the design rather than something I invented — but "everyone does it" isn't documentation, and it should be written down.

I also made the clock injectable (now: () => number), because a time-dependent function you can't control in a test is a time-dependent function you can't test.

The prefix should be a type, not a convention

The prefixed-ID pattern is Stripe's: cus_MJA953cFzEuO1z, pi_3LKQhv…. The stated reasons are readable logs and polymorphic lookup — you can tell what an ID is without a schema.

The obvious next step in TypeScript is making the compiler enforce it. There are two ways, and the difference matters.

Branded types. TypeID's TypeScript implementation uses:

type TypeId<T> = string & { __type: T };

That's a plain property name, not a unique symbol. Two consequences I verified rather than assumed: it's forgeable with a single as cast, and the brand doesn't survive string operations — call .toUpperCase() and you get a plain string back.

The sharper issue is that the compile-time and runtime prefix checks aren't linked. This type-checks cleanly:

fromString<'user'>('post_01h5fskfsk4fpeqwnsyz5hj55t')  // ✅ returns TypeId<'user'>

You asked for a user ID, you handed it a post ID, and the compiler agreed with you.

Template literal types. The alternative is to describe the string as what it actually is:

id("user")   // type: `user_${string}`

No brand, no fiction — a structural type the compiler can verify. It composes with narrowing (startsWith("user_") works the way you'd hope), it survives being passed through generic code that expects string, and there's nothing to forge because there's no claim beyond the shape.

It isn't free either, and one of the costs is a wart in my own API. A template literal type degrades to string under arbitrary string manipulation, and it can only express "this has the right prefix shape," never "this is a valid ID." That would be fine if the runtime side picked up the slack. It doesn't:

isId("user_!!!! spaces", "user")  // → true

isId is a startsWith check and nothing more, and the Zod companion inherits the same predicate — so zId("user").safeParse("user_@@@!") succeeds. If you reach for it as an input validator at a trust boundary, you have a hole. The name promises more than the function delivers, which is my fault and is being fixed; the type is honest, the runtime guard isn't yet.

The related sharp edge: there's no version or variant marker in the format, so getTimestamp() will cheerfully decode the first nine characters of a non-sortable id() and hand you a plausible date. Measured across 20,000 random IDs, about 64% produce an in-range Date. A reader function that can't tell it's been handed the wrong kind of input is a reader function that lies.

The honest comparison

Comparison of UUIDv4, UUIDv7, ULID, nanoid, cuid2, TypeID and prefID across prefix support, type safety, sortability, entropy, dependencies and specification status

prefID's 142.9 bits versus nanoid's 126 is a length choice, not a cleverness win — 24 characters instead of 21, both using a CSPRNG. And nanoid is smaller: 516 bytes gzipped for its whole entry against prefID's 992 bytes for id alone, 2,315 for the full surface. "✓ ms" means k-sortable to millisecond granularity; within a millisecond, ordering across processes is not guaranteed for any of them. One implementation detail worth naming: the random body uses masked rejection sampling rather than % radix, so custom alphabets stay unbiased — the twenty-line version most people write is subtly skewed.

One more thing the figure doesn't capture: nanoid's default alphabet includes _ and -, which is quietly hostile to the prefixed-ID pattern, because id.split("_")[0] isn't reliable when the random body can contain underscores.

Where my own library still has this bug

I opened this post with an invariant: a sortable alphabet must be in ascending code-point order. My library asserts it at construction time. Which felt airtight right up until I asked the obvious follow-up question — ascending according to whom?

JavaScript's default string comparison uses code points. Your database's does not.

MySQL's default collation (utf8mb4_0900_ai_ci) and Postgres under an ICU or en_US.UTF-8 locale both order letters case-insensitively: a < A < b < B. Under that comparator, mixed-case base62 stops being sortable:

const earlier = "evt_00000000Z";   // encoded rank 35
const later   = "evt_00000000a";   // encoded rank 36 — one millisecond later

[later, earlier].sort();                           // ['…Z', '…a']  ✅ code points: correct
const c = new Intl.Collator("en-US");
[earlier, later].sort((a, b) => c.compare(a, b));  // ['…a', '…Z']  ❌ inverted

ORDER BY id in your application returns one order. ORDER BY id in your database returns another. Same bug as the opening of this post, one layer down — and I shipped it as the default.

Two fixes, neither of which I get to feel clever about: use the exported Crockford base32 alphabet, which is single-case and therefore collation-proof, or force a binary collation on the column (utf8mb4_bin, or COLLATE "C" in Postgres). Making the single-case alphabet the default for sortable IDs is the right long-term answer.

The generalisable lesson is the one I'd keep even if you never touch any of this: "sortable" is not a property of an ID format. It's a relationship between an encoding and a specific comparator. I verified my encoding against JavaScript's comparator, declared victory, and shipped a default that violates the invariant under the comparator that actually runs in production — the database's. Every layer that sorts your IDs gets a vote, and they don't all agree.

When you should not use the thing I built

This is the part I'd want to read first, so:

Use TypeID if you need cross-language IDs. TypeID has a written specification, conformance fixtures, and implementations across roughly two dozen languages. prefID is TypeScript/JavaScript only. If a Go service and a Python job both mint IDs, a shared spec is worth more than any ergonomic detail in this post.

Use TypeID or UUIDv7 if you want native UUID storage. This is the big one. A TypeID's suffix is a UUIDv7, so you can store the 128 bits in a Postgres uuid column — 16 bytes, native indexing — and keep the prefix in your application layer. prefID's base62 string is a string; you store it as text/varchar. On a large table with several secondary indexes that difference is real, because InnoDB copies the primary key into every secondary index.

Use UUIDv7 if you need a standard. It's a Standards Track RFC with named authors, normative language and test vectors. Auditors, other teams and future maintainers all know what it is. "A library I found on npm" is a harder sell.

Use nanoid if you just need a random string. It's smaller, extremely well-tested, and if you don't need prefixes or sortability, everything in this post is overhead.

Use cuid2 if unguessability is your priority. It's deliberately not sortable, precisely because a timestamp in an ID leaks information.

Don't use a sortable ID as a bearer secret. Not an invite link, a share URL, a password-reset token, or an unsubscribe link. In monotonic mode the same-millisecond siblings are the current ID with its last character incremented, so one leaked sample enumerates a burst. RFC 9562 names this pattern as a hazard. Use a pure random token.

The strongest objection comes from Stripe

If prefixed IDs are Stripe's idea, it's worth reading what Stripe actually tells API consumers. Their upgrades documentation lists, under backward-compatible changes — things they may do without warning:

Changing the length of object IDs, and adding or removing fixed prefixes.

Stripe invented the pattern and explicitly tells you not to depend on the prefix. They lengthened their IDs in production in 2013 and tell integrators to handle IDs up to 255 characters.

The resolution, I think, is that these are two different situations. Stripe is talking about consuming someone else's IDs across an API boundary you don't control — there, parsing the prefix is coupling to an implementation detail, and they're right. Inside a system where you mint the IDs, the prefix is your own schema, and encoding it in the type system makes it checkable rather than decorative.

Two more objections worth conceding rather than deflecting:

A prefix doesn't make an ID unguessable. All the unguessability lives in the random suffix. A typed ID with weak entropy is a weak ID with better logging.

Prefixes don't fix authorization. IDOR is unaffected by ID format. An unguessable, beautifully typed ID in front of a missing access check is still a missing access check.

What I'd take away even if you never use any of this

  1. If your encoding has an ordering requirement, assert it at construction. Most sortable-ID bugs aren't in the sorting; they're in an alphabet that quietly doesn't satisfy the invariant the sort assumes.

  2. "Sortable" is a relationship, not a property. Check your encoding against every comparator that will touch it — your language's, your database's collation, your search index's. They disagree more often than you'd think.

  3. "Monotonic" is a claim about clocks. Ask what a scheme does when time moves backwards, and what that costs. If its spec doesn't say, its implementations disagree.

  4. A type that can't be verified is documentation with extra steps. If your brand can be forged with as, it's a naming convention wearing a type's clothes — and if your runtime guard is a startsWith, so is that.

  5. Cross-language reach beats local ergonomics more often than library authors like to admit — mine included.

The library is prefID (MIT, zero dependencies) if these specific tradeoffs are the ones you want. The isId weakness and the collation default above are tracked in the issue backlog rather than quietly known. If they aren't your tradeoffs, TypeID and UUIDv7 are genuinely good — and this post will have done its job if you go and check your alphabet ordering, and then your database collation, either way.


Contribution links Repository

Subscribe for new contributions

Get an email when I publish a new open-source write-up — how I approached the issue, the code, and lessons learned. No spam.