UUID vs ULID vs NanoID: Picking an ID Scheme in 2026
UUIDv4, UUIDv7, ULID and NanoID compared on sortability, index behaviour, size and computed collision odds, with a decision guide.
Picking an ID scheme looks like a five-minute decision and then taxes every insert for the life of the system. The tax is invisible at ten thousand rows and expensive at four hundred million, which is why it usually gets noticed long after the choice was made.
Here are the real options and what each one costs.
Why not just use auto-increment integers
The default in a lot of older schemas is a BIGINT AUTO_INCREMENT or a Postgres SERIAL. They are tiny (8 bytes), they sort naturally, and a B-tree loves them because every new row appends to the right edge of the index. So why does anyone reach for anything else?
Two reasons, and both are real.
The first is information leakage. A sequential ID tells anyone who sees it roughly how many records you have and how fast you are growing. If /orders/10432 exists, then /orders/10433 is your next order, and a competitor can poll the endpoint to count your daily volume. This is the classic "German tank problem" applied to your business metrics. It also makes enumeration attacks trivial: if your authorization has any gap, an attacker walks the entire ID space by counting up from 1.
The second is distributed generation. A single sequence is a single point of coordination. The moment you shard, or you want a client to generate an ID before the row hits the database, or you merge two databases, a global counter becomes a bottleneck or an outright conflict. You cannot mint an auto-increment ID offline.
So the question is rarely "integers or something fancy." It is "which non-sequential scheme costs me the least."
What a UUID actually is
A UUID is 128 bits. That is the whole story for storage: 16 bytes raw, or 36 characters in the canonical hyphenated text form like f47ac10b-58cc-4372-a567-0e02b2c3d479.
Inside those 128 bits there is a 4-bit version field and a 2-bit variant field. The version nibble is the 13th hex character (the first digit of the third group). In the example above it is 4, so that is a UUIDv4. You can confirm the version of any UUID with the UUID Version Detector if you are debugging data you did not generate yourself.
The versions that matter in practice:
- v4: random. 122 of the 128 bits are random (6 bits go to version and variant). This was the default everyone used for a decade.
- v7: time-ordered. The first 48 bits are a Unix millisecond timestamp, the rest is random, as defined in RFC 95621.
- v1: timestamp plus MAC address. Mostly historical, and the MAC address leak is a privacy problem nobody wants now.
If you want to generate either kind to look at, the UUID Generator produces v4 and v7.
The v4 problem nobody warns you about
UUIDv4 is fine until it becomes your primary key on a large table. Then it slowly wrecks you.
The issue is index locality. A B-tree (Postgres) or a clustered index (MySQL InnoDB stores the row data physically ordered by primary key) wants new keys to land near recent keys, so the active part of the index stays in memory and writes append cleanly. UUIDv4 is uniformly random, so every insert lands at a random position in the index. On a big table that means:
- Random page reads to find the insertion point, because the relevant index page is probably not in the buffer pool.
- Page splits all over the tree instead of clean appends at the end.
- A working set that is effectively the whole index, so your cache hit rate falls off a cliff as the table grows past RAM.
The symptom is distinctive: insert latency creeping upward as a table grows, with no query change to explain it, and a buffer pool hit rate that degrades in step with table size rather than with traffic.
The fix is a generator swap. Same column type (uuid), same 16 bytes, no migration of existing rows. New values increase over time, so inserts append near the right edge again.
-- Postgres 18 ships uuidv7() natively:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
total numeric NOT NULL
);
PostgreSQL 18 provides uuidv7() alongside uuidv4() and gen_random_uuid()2. If you are on an older Postgres, generate v7 in the application layer or use the pg_uuidv7 extension. The point is the same: the column does not change, only the generator does.
One caveat. v7 embeds a millisecond timestamp in the clear, so anyone holding the ID can read the creation time. For an internal primary key that is usually fine. For a public-facing identifier in a URL, decide whether you are comfortable leaking that.
ULID
ULID predates UUIDv7 and solves the same "time-ordered, distributed" problem, but it picks a different text encoding.
A ULID is also 128 bits: 48 bits of millisecond timestamp, 80 bits of randomness. The difference is how you write it down. Instead of hex with hyphens, ULID uses Crockford's base32, which gives you a 26-character string with no hyphens:
01ARZ3NDEKTSV4RRFFQ69G5FAV
Crockford base32 was designed to be read by humans and typed without errors. It excludes the letters I, L, O, and U, so you cannot confuse a one and an I or a zero and an O, and it is case-insensitive. The encoding is also lexicographically sortable, meaning a plain string sort gives you time order for free, which is handy if your storage layer treats the ID as text.
ULID's appeal over UUIDv7 was mostly the 26-char compact text form versus UUID's 36 chars. Now that v7 is a real standard, the honest take is that ULID and UUIDv7 are close enough that the deciding factor is ecosystem support, not technical merit. If your database has a native uuid type (Postgres does, and it stores 16 bytes), UUIDv7 wins because ULID has no native column type and you end up storing it as char(26) text or doing your own binary packing. If you are in a key-value store or a system where everything is a string anyway, ULID's shorter, cleaner text is genuinely nicer.
NanoID
NanoID is a different animal. It is not time-ordered and it is not a fixed 128 bits. It is a small, configurable random string generator, and its whole pitch is being good in URLs.
The default is 21 characters drawn from a 64-character URL-safe alphabet (A-Za-z0-9_-). That gives roughly 126 bits of randomness, which is in the same ballpark as a UUIDv4's 122 random bits, but in 21 characters instead of 36. Both the length and the alphabet are knobs you can turn.
V1StGXR8_Z5jdHi6B-myT
Because the alphabet is URL-safe by default, you drop a NanoID straight into a path or query string with no percent-encoding. No hyphens to deal with, no case sensitivity surprises, and you can shrink it when you do not need 126 bits. A short link service, for example, might use a 10-character NanoID. If you want NanoID-style random strings to experiment with, the Random String Generator lets you set length and alphabet directly, and for cryptographic session tokens specifically the Secure Token Generator is the right tool.
NanoID is the pick when an ID lives in a URL that humans see or share and does not need to sort by time.
KSUID, briefly
KSUID (K-Sortable Unique Identifier, from Segment) deserves a mention because you will see it in older systems. It is 160 bits: a 32-bit second-resolution timestamp plus 128 bits of randomness, encoded as a 27-character base62 string. It is sortable like ULID and UUIDv7, but it is bigger and only second-granular on the timestamp. In 2026 there is little reason to pick it over UUIDv7 for new work, but it is a perfectly reasonable scheme if you inherit it.
The comparison matrix
| Scheme | Bits | Text length | Time-ordered | URL-safe out of the box | Native DB type | Leaks timestamp |
|---|---|---|---|---|---|---|
| UUIDv4 | 128 | 36 chars | No | No (has hyphens) | Yes (uuid) | No |
| UUIDv7 | 128 | 36 chars | Yes | No (has hyphens) | Yes (uuid) | Yes (ms) |
| ULID | 128 | 26 chars | Yes | Yes | No (store text) | Yes (ms) |
| NanoID | configurable (~126 default) | 21 chars default | No | Yes | No (store text) | No |
| KSUID | 160 | 27 chars | Yes | Yes | No (store text) | Yes (sec) |
A few things worth pulling out of that table. Only UUIDv4 and NanoID hide their creation time. Only UUID has a native 16-byte database column type you should actually use. Everything time-ordered (v7, ULID, KSUID) gives you the index-locality win on inserts; UUIDv4 is the only one that does not.
Collision probability, with real numbers
People worry about collisions far more than the math justifies. The relevant tool is the birthday bound: the chance of any collision in a set climbs much faster than intuition says, because every pair of items is a chance to collide. The rough rule is that you expect a 50% chance of one collision once you have generated about the square root of the total space.
The formula for the number of IDs n that gives a collision probability p over a space of N values is n = sqrt(2N ln(1/(1-p))). Worth stating because it is the thing to compute rather than the thing to feel.
For UUIDv4 the space is 2^122, the random bits. A 50 percent chance of one collision arrives at about 2.71 quintillion IDs, which is a billion UUIDs per second for 86 years. For any real volume the probability rounds to zero, and you do not need to check for v4 collisions.
That formula is worth sanity-checking against something published. nanoid states that "for there to be a one in a billion chance of duplication, 103 trillion version 4 IDs must be generated"3. The formula gives 1.03 x 10^14. Same number.
A 21-character NanoID has 126 bits, so it is larger still: a one percent chance of collision needs about 1.31 quintillion IDs, which at a thousand per second is roughly 41 million years.
The interesting case is not the default. It is what happens the moment you shorten it.
Cut a NanoID to eight characters and the one percent point arrives at 2.38 million IDs. A busy link shortener reaches that, so a short ID needs a uniqueness constraint and a retry on conflict, or more characters.
Collision risk is not a property of the scheme. It is a property of how many bits you kept, and shortening is precisely the act of giving them back.
Decision guide
A decision procedure that holds up.
- Primary key on a real database table? Use UUIDv7. Native
uuidcolumn, 16 bytes, time-ordered so inserts stay cheap. This is the new default and it replaces v4 with no schema change. - Public identifier in a URL that people see or paste? Use NanoID. URL-safe, compact, and crucially it does not leak when the row was created. Pick a length that gives you the bits you need (21 is plenty; do not go below ~12 without thinking about the birthday bound).
- You need the ID to hide its creation time even internally? Use UUIDv4 or a random NanoID. v7 and ULID both expose a millisecond timestamp to anyone holding the value.
- You are in a string-only store (some KV stores, logs, event streams) and want time-sortable keys? ULID is nicer to read and type than UUIDv7 there, since you are storing text either way.
- You want the Stripe experience (
cus_xxx,pi_xxx)? Prefix a NanoID with a short type tag:order_V1StGXR8Z5jdHi6BmyT. The prefix makes IDs self-describing in logs and stops you from passing a customer ID where an order ID belongs. This is a convention layered on top of NanoID, not a separate scheme.
If you take one thing away: stop reaching for UUIDv4 as the reflex primary key. It was the right answer in 2015. UUIDv7 is the right answer now, and the migration is a generator swap, not a data migration.
Summary
Auto-increment integers are compact and fast but leak your record counts and cannot be generated offline. UUIDs are a 128-bit standard whose version nibble tells you how they were made. UUIDv4 is pure randomness and quietly destroys B-tree insert performance at scale because every key lands in a random place. UUIDv7 fixes that by leading with a millisecond timestamp, stays a drop-in uuid column, and is the sensible 2026 default for primary keys. ULID solves the same time-ordering with a 26-char Crockford base32 string and shines in string-only stores. NanoID is the URL pick: short, configurable, URL-safe, and it does not leak a timestamp, with collision odds that stay negligible as long as you keep enough bits. KSUID is fine if you inherit it but rarely worth choosing fresh. Match the scheme to where the ID lives, watch the timestamp leak on the sortable ones, and respect the birthday bound the moment you start trimming length.
Sources
Every number in this article traces to a source below. Where a claim could not be sourced, it was cut rather than softened.
- Primary sourceIETF
The definition of UUID version 7 as a Unix-epoch millisecond timestamp followed by random bits, and the layout of the version and variant fields within the 128 bits.
- Primary sourcePostgreSQL Global Development Group
That PostgreSQL 18 ships uuidv7() generating a version 7 time-ordered UUID natively, alongside uuidv4() and gen_random_uuid().
- Primary sourceGitHub
The 21-character default over a 64-character URL-safe alphabet giving 126 bits, and the project's published collision figure that 103 trillion version 4 IDs are needed for a one in a billion chance of duplication, which the birthday formula used here reproduces exactly.
Topics
- UUID
- ULID
- Nanoid
- Identifiers
- Databases
Tools mentioned in this article
- UUID Generator - Generate UUID v4 identifiers, single or in bulk.
- UUID Version Detector - Identify the version (v1, v3, v4, v5, v6, v7, v8) and variant of any UUID and extract embedded timestamps.
- Random String Generator - Generate random strings with configurable length and character set.
- Secure Token Generator - Generate cryptographically secure random tokens in base64url, hex, alphanumeric or UUID v4 format.
Get new tools by email
New tools and the occasional deep-dive, about once a month. No spam, no sharing your address, unsubscribe in one click.