UUID vs ULID vs NanoID: Picking an ID Scheme in 2026
A practical comparison of UUIDv4, UUIDv7, ULID, and NanoID for sortability, index performance, size, and collision odds, with a clear decision guide.
I migrated a 400-million-row table off UUIDv4 primary keys two years ago, and the write throughput on that table roughly doubled overnight without touching the application code. That experience is the reason I have opinions here. Picking an ID scheme looks like a five-minute decision and then quietly taxes every insert for the life of the system. This is a walk through the real options in 2026 and when each one earns its place.
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. New in RFC 9562 (2024).
- 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.
On the table I migrated, the symptom was insert latency creeping up as the table grew, with no query change to explain it. The fix was switching the primary key generation from v4 to v7. Same column type (uuid), same 16 bytes, no migration of existing rows needed. Inserts went back to appending near the right edge because v7 values increase over time. Write throughput roughly doubled and the buffer pool stopped thrashing.
-- Postgres 18 ships this built in:
CREATE TABLE orders (
id uuid PRIMARY KEY DEFAULT uuidv7(),
total numeric NOT NULL
);
If you are stuck 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 what I reach for when the ID is going to live in a URL that humans see or share, and I do not need it 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.
For UUIDv4, the space is 2^122 (the random bits). The square root is 2^61, about 2.3 billion billion. To put a number on the small-probability end: you would need to generate roughly 1 billion UUIDs per second for about 85 years to hit a 50% chance of a single collision. For practical volumes the probability rounds to zero. You do not need to check for v4 collisions.
NanoID with its 21-char default is 2^126, even larger. The project’s own table puts it like this: at 1000 IDs per second, you would need about 49 thousand years to reach a 1% chance of one collision. The interesting case is when you shrink it. Cut a NanoID to 8 characters with the 64-char alphabet and you have 2^48 of space, about 281 trillion. By the birthday bound the 50% point sits near 2^24, roughly 16.7 million IDs. A busy short-link service can hit that, so a short NanoID needs either a uniqueness constraint with retry on the rare conflict, or more characters. The lesson: collision risk is entirely a function of how many bits you keep, and shortening an ID is exactly the act of throwing bits away.
Decision guide
Here is how I actually decide.
- 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.
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.