Choosing an identifier format

A primary key, a public identifier and a secret are three different problems. Almost every bad identifier decision comes from answering them with one value.

Updated 2026-09-05

Three jobs, and why conflating them is the actual mistake

Comparison articles in this area usually rank formats against each other on a single axis, as though there were a best identifier. There is not, because an identifier is asked to do up to three unrelated jobs and the properties that help with one actively hurt another.

A database primary key wants to be small, to compare fast, and to insert without scattering writes across the index. Unpredictability is irrelevant and sortability is valuable.

A public-facing identifier, the kind that goes in a URL or an email, wants to be short, to survive copy and paste and a double-click selection, to need no percent-encoding, and to reveal nothing about your business. Sortability is a liability here, because everything sortable leaks a timestamp.

A secret wants at least 128 unpredictable bits from a CSPRNG and nothing derived from time, sequence or host. Length and prettiness barely matter.

Once these are separated, most of the disagreement in the field dissolves. Use a time-ordered value as the key, a random short value as the public handle, and a long random value as the secret. Three columns is not a design failure. Using one value for all three is.

The formats, side by side

Random bits below counts only bits an attacker cannot predict, which is not the same as the total size. The one percent collision column uses n = sqrt(2 x 2^bits x ln(1/0.99)) over the random bits only. That is a threshold, not a fifty percent birthday bound, and the two conventions are 8.3x apart at the same bit count, so never quote one alongside the other.

Seven formats, measured on the properties that decide the choice
FormatCharsRandom bits1% collisionSortableURL safeLeaks timeBest at
UUID v4361223.27e17NoYesNoA general default, and anything that must be unguessable and standard
UUID v736741.95e10 per msYes, to the msYesYes, to the msPrimary keys on B-tree engines
ULID26801.56e11 per msYes, to the msYesYes, to the msA shorter sortable value where a UUID type is not required
Nano ID (21)211261.31e18NoYes, by constructionNoPublic identifiers in URLs
KSUID271282.61e18Yes, to the secondYesYes, to the secondEvent and log identifiers where second-level ordering is enough
Base58 short UUID221223.27e17NoYesNoShortening a UUID you already have, losslessly
bigint identityup to 190neverYesYesIndirectlySingle-writer primary keys where nothing is exposed

There is no such thing as a sortable identifier that hides its creation time. Sortability is a timestamp in the high bits. If you need both properties you need two values.

For a database primary key

On a single-writer PostgreSQL or MySQL instance where the key is never exposed, a bigint identity column is still the best primary key by a clear margin. It is 8 bytes against 16, it compares as an integer, it always appends, and the sequence is a solved problem. Reject it only when you need identifiers minted outside the database, across shards, or offline on a client.

When you do need that, UUID v7 is the default. It appends to the right edge of the index the way a sequence does, it needs no coordination between writers, and it is a standard type your database already understands. Store it as 16 raw bytes, never as varchar(36), which more than doubles the index and turns every join into text comparison.

Two exceptions that get missed. On SQL Server, the uniqueidentifier type is documented as not ordering by bit pattern, so v7 does not give you locality there and the usual advice does not apply. And on hash-partitioned distributed stores such as DynamoDB, Cassandra and Spanner, a monotonic key is the failure mode rather than the fix, because it concentrates every write on one partition. Random identifiers are correct on those systems.

There is a live and genuine disagreement here worth knowing about rather than resolving by fiat. The CUID2 documentation argues that random identifiers generally outperform k-sortable ones, because k-sortable keys create hotspots that hurt load balancing across hosts. RFC 9562 section 6.11 argues that index locality is worth an order of magnitude or more. Both are correct in their own regime. The first is describing a distributed hash-partitioned store, the second a single-node B-tree. Which one applies to you is a fact about your architecture, not a matter of opinion.

For a public-facing identifier

Nano ID at its default size is the strongest general answer. Twenty-one characters, a 64-symbol alphabet of A-Za-z0-9 plus underscore and hyphen, 126 unpredictable bits, and no character that needs percent-encoding in a URL or breaks a double-click selection. It carries no timestamp and no sequence, so it reveals nothing.

Two details about it are worth knowing because they are frequently misreported. Its alphabet is that set of 64 characters, but not in sorted order: the source order is deliberately scrambled to hit substrings in the Brotli static dictionary so that the library compresses smaller, and the source comment says so. And nanoid does not use rejection sampling for its default alphabet, because 64 is a power of two, so masking a byte with 63 maps 256 values onto 64 indices exactly four times each. That is perfectly uniform with zero rejection. It does use a modulo cutoff for custom alphabets, on a different code path.

A base58-encoded UUID is the other good option, and it has a specific advantage: it is lossless. The same 128 bits you already store as a uuid column, rendered as 22 characters that exclude 0, O, I and l. You can decode it back to the canonical form at any time, so your database keeps a uuid type and your URLs get a shorter string. The npm short-uuid package does about 3.59 million downloads a month and has grown 3.07x over two years, and none of the seven competitor sitemaps we grepped, covering 1,159 URLs, has a page for it.

Do not use a sequential integer here. It tells anyone who signs up exactly how many customers you have, and it invites enumeration. And do not use Sqids or Hashids as a substitute for unguessability. The Hashids README states plainly that it is not a security tool and not an encryption library, hashids.org now redirects to sqids.org, and Sqids itself warns that the identifiers can reveal user counts to anyone who works out the alphabet. They are a display encoding for an integer, and the integer is still there.

For a secret

The requirement is at least 128 unpredictable bits drawn from a CSPRNG, containing nothing derived from time, sequence, hostname or process. That is the whole specification, and it rules out most of the formats above.

Nano ID at 22 characters gives exactly 132 bits and is a fine token. Twenty-four random bytes rendered as base64url gives 32 characters and 192 bits. Thirty-two hexadecimal characters gives exactly 128. A UUID v4 gives 122, which is close enough that it is usually fine, though it wastes 36 characters on a value a purpose-built token expresses in 22.

The formats to keep away from secrets are the ones whose bits are structure rather than entropy. UUID v7 has 74 random bits and 48 that are a readable timestamp. ULID in monotonic mode makes consecutive values predictable by design. KSUID leaks its creation second. Snowflake and xid contain essentially no unpredictable bits at all, and Sqids and Hashids have none by construction, because they are reversible encodings of an integer. All of those are uniqueness or display mechanisms, not secrets.

What the popularity numbers do and do not tell you

It is tempting to settle this with download counts, and it does not work. nanoid records enormous npm numbers, but they are largely transitive: postcss declares nanoid as a dependency and out-downloads it, so most of those installs are people who have never heard of it and will never call it. A dependency count measures how many build tools pull a package in, not how many engineers chose it.

The other instruments point the same way about human interest in the newer formats. Stack Overflow carries 3,325 questions tagged uuid; three of the newer formats have tags carrying 19, 11 and 2 questions, and a fourth has no tag at all. English Wikipedia has no article for ULID, Nano ID or KSUID. Related search queries for all of them are dominated by package-install intent rather than by people trying to understand a trade-off. None of that makes the formats bad. It means the community knowledge base around them is thin and you should read the reference implementation rather than a blog post.

ULID in particular deserves an honest status report, because both the enthusiastic and the dismissive readings are wrong. The five most recent commits to the specification repository are all dated 23 May 2019. Issue 91, titled deprecate in favour of UUIDv7, was opened in October 2023 and is still unresolved. RFC 9562 lists ULID first among the sixteen prior-art implementations it analysed while drafting version 7. And the ulid npm package still does about 10.8 million downloads a week. It is not dead and it is not moving. A real bias bug in the reference RNG, which divided by 0xff instead of 0x100 and made the first character about 29% more likely than the last, was only fixed in release 3.0.2 in November 2025. Its ulidToUUID helper reinterprets the bytes without setting the version nibble or the variant bits, so what it returns is UUID-shaped but is not a valid UUID of any RFC 9562 version.

One more implementation trap, in KSUID. Its README describes an epoch of 13 May 2014, and people implementing from the prose reach for midnight. The reference implementation defines the epoch as 1400000000, which is 2014-05-13T16:53:20Z. Anyone who assumed midnight is about 16.9 hours out, which is a bug that will not show up until two implementations have to agree.

Doing this in your own code

Store the bytes, not the text
-- 16 bytes, not 36. A varchar(36) primary key costs more than twice the
-- index space and is compared as text in every join.
CREATE TABLE orders (id uuid PRIMARY KEY DEFAULT uuidv7());        -- PostgreSQL
CREATE TABLE orders (id BINARY(16) PRIMARY KEY);                   -- MySQL / InnoDB

-- MySQL round trip, swap_flag = 1 reorders v1 fields for locality.
-- It is a no-op you do not want for v7, whose fields are already ordered.
SELECT BIN_TO_UUID(id) FROM orders;
The three jobs, in code
// 1. Primary key: time ordered, 16 bytes, leaks a timestamp you control.
const pk = uuidv7();

// 2. Public identifier: short, URL safe, no timestamp, no ordering.
import { nanoid } from "nanoid";
const publicId = nanoid();          // 21 chars, 126 bits

// 3. Secret: at least 128 unpredictable bits, nothing derived from
//    time, sequence or host.
const token = btoa(String.fromCharCode(...crypto.getRandomValues(new Uint8Array(24))))
  .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");   // 32 chars, 192 bits

Three lines, three formats. Using one value for two of these jobs is the mistake this article exists to prevent.

Questions

Can I just use UUID v4 for everything?

You can, and it is a defensible default. You give up index locality on high-insert tables, and you spend 36 characters in URLs where 21 or 22 would do. What you get in return is one format, universally supported, with no timestamp leak and 122 bits of unpredictability. Uniformity across a codebase has real value and this choice is not a mistake.

Is ULID or UUID v7 the better choice?

They solve the same problem and v7 has the stronger position: it is an IETF standard, the database types already exist, and it has native support in PostgreSQL 18, .NET 9 and google/uuid. ULID is 26 characters against 36 and has 80 random bits against 74, so it is genuinely more compact. Choose ULID if the string length matters to you and you accept a specification that has not moved since 2019.

Should I put a prefix on my identifiers, like cus_ or inv_?

Yes, for anything that appears in an API or a support conversation. A typed prefix makes it obvious when the wrong identifier has been pasted into the wrong field, and it costs a few bytes. Just do not count the prefix towards the entropy: a prefixed key has exactly the unpredictability of its random part, and a published guide in this space gets that wrong by quoting the full length.

What is the shortest identifier I can safely use in a URL?

It depends on whether the URL is a secret. If the identifier merely names a resource that is protected by authentication, ten to twelve random alphanumeric characters is fine and collisions are the only concern. If knowing the URL grants access, treat it as a secret and use at least 22 base64url characters, which is 132 bits.

Do I need to worry about collisions at all?

At the bit counts in the table above, no. A 1% collision chance for Nano ID at 21 characters arrives at 1.31 x 10^18 identifiers, which nobody will generate. The realistic risk is a broken entropy source, such as a fleet of containers started from an image with a pre-seeded userspace pool, which produces duplicates that no amount of bits would have prevented.

Try it

Related reading