Skip to main content
Developer Tools

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.

By 12 min read
Article title card. Two rows of small blocks, the upper scattered with amber at random and the lower shaded in order, with the line: random, or sorted by time

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.

Where a new row lands in a B-tree index under a random UUIDv4 key versus a time-ordered UUIDv7 key: scattered across every page for v4, concentrated at the right edge for v7
Same sixteen bytes. The difference is where the next write goes.

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

SchemeBitsText lengthTime-orderedURL-safe out of the boxNative DB typeLeaks timestamp
UUIDv412836 charsNoNo (has hyphens)Yes (uuid)No
UUIDv712836 charsYesNo (has hyphens)Yes (uuid)Yes (ms)
ULID12826 charsYesYesNo (store text)Yes (ms)
NanoIDconfigurable (~126 default)21 chars defaultNoYesNo (store text)No
KSUID16027 charsYesYesNo (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.

IDs generatable before a one percent collision chance at five NanoID lengths: 2.38 million at eight characters, 152 million at ten, 9.74 billion at twelve, 39.9 trillion at sixteen and 1.31 quintillion at twenty-one
Six bits per character, and the consequences are not linear.

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.

  1. Primary key on a real database table? Use UUIDv7. Native uuid column, 16 bytes, time-ordered so inserts stay cheap. This is the new default and it replaces v4 with no schema change.
  2. 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).
  3. 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.
  4. 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.
  5. 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.

  1. 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.

  2. Primary sourcePostgreSQL Global Development Group

    That PostgreSQL 18 ships uuidv7() generating a version 7 time-ordered UUID natively, alongside uuidv4() and gen_random_uuid().

  3. 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

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.

Related articles

Article title card. A pair of curly braces containing an amber double slash, with the line: the extension is not the format
Developer Tools

Why tsconfig.json Allows Comments and package.json Does Not

Both files end in .json. One accepts comments and trailing commas, the other throws. The difference is not the extension - it is which parser reads the file.

Article title card. A large pair of amber curly braces, with the line: one format, three dialects
Developer Tools Guide

The Developer's JSON Guide: Everything Worth Knowing in 2026

The spec, the dialects that disagree, the numbers that quietly break, and the security advice that points at the wrong recursion. With the cluster map.

Article title card. Three grey squares and an arrow leading to four narrower amber bars, with the line: 3 bytes become 4 characters
Developer Tools

Base64 Explained: What It Is, Why It Exists, and When Not to Use It

What Base64 does to your bytes, the three variants that produce most decode failures, and the places it still earns its keep.