If you're building a new service today and you need a primary key for users, orders, events, or anything else: use UUIDv7 in Postgres or MySQL, use ULID if you need lexicographically sortable string IDs across systems, use Nano ID for short URL slugs and share codes, and use CUID2 if you need horizontal scale plus collision resistance without leaking timestamps. That's the decision in one sentence. The rest of this post explains why those defaults work, what the trade-offs actually look like in production, and where each scheme breaks down.
The four schemes here aren't interchangeable. They optimize for different things — some prioritize sortability, some prioritize URL friendliness, some prioritize unguessability. Picking the wrong one means either bigger indexes, fragmented inserts, or worse, a slow leak of internal information through the IDs themselves.
What These IDs Actually Look Like
Before getting into trade-offs, look at one of each. Every example below was generated fresh — none are placeholders.
UUIDv4 → 6f9619ff-8b86-d011-b42d-00cf4fc964ff
UUIDv7 → 018f9c12-3a4b-7c5d-8e6f-789012345abc
ULID → 01HZX3RVQ5JKPN7C2A8WBM9YEF
Nano ID → V1StGXR8_Z5jdHi6B-myT
CUID2 → k8h3z2g0nq1m7vx4d9rf2lap
UUIDs are 36 characters with hyphens (32 hex chars plus 4 dashes), 128 bits of underlying data. ULIDs are 26 characters of Crockford Base32, also 128 bits. Nano IDs default to 21 characters of URL-safe alphabet, ~126 bits of entropy. CUID2 defaults to 24 characters and is collision-resistant by design rather than by raw entropy alone.
Why UUIDv4 Was Always a Bad Database Key
UUIDv4 is purely random. That randomness is the feature — it means two services in different data centers can mint IDs without coordinating and the collision odds remain astronomical. But for B-tree primary keys, randomness is a problem.
When you insert a UUIDv4 row, Postgres or MySQL has to find a spot in the middle of the index, often on a page that isn't in cache. Every insert is essentially a random write. As the table grows, the index fragments. Cache hit rates drop. Inserts get slower under load.
The classic benchmark from Percona on MySQL with InnoDB showed sequential BIGINT keys outperforming random UUIDv4 keys by 2-5x on insert-heavy workloads, with the gap widening as the dataset exceeded buffer pool size. The shape is the same on Postgres.
If you've already shipped UUIDv4 and your indexes are healthy, don't panic — modern storage and big buffer pools mask a lot of this. But for a greenfield service in 2026, there's no reason to start with UUIDv4 when better options exist.
UUIDv7: The Boring Right Answer for Most Databases
RFC 9562 standardized UUIDv7 in 2024. The format is simple: the first 48 bits are a Unix millisecond timestamp, then 12 bits for sub-millisecond ordering, then 62 bits of randomness.
018f9c12-3a4b-7c5d-8e6f-789012345abc
└─ timestamp ──┘└rand-12┘└─ random 62 bits ──┘
The result is a UUID that sorts roughly chronologically. New rows append near the right edge of the index, the same way auto-incrementing integers do. You get the cache-friendly insert pattern of sequential keys plus the distributed-friendly nature of UUIDs.
UUIDv7 is now native in Postgres 18 (gen_uuid_v7()), available in libraries for every language, and supported in pgmodel and Prisma. If your database is the bottleneck and you're starting fresh, UUIDv7 is the right default. Generate one with our UUID generator to see the timestamp prefix in action.
The one trade-off: UUIDv7 leaks creation time. If exposing the millisecond a row was created is sensitive (think: medical records, abuse reports), use a different scheme or rotate to a public-facing ID.
ULID: When You Want Sortable String IDs Outside the Database
ULID predates UUIDv7 by years and solves the same sortability problem with a different shape. The encoding is Crockford Base32, which excludes ambiguous characters (no I, L, O, U) and is case-insensitive on read.
01HZX3RVQ5JKPN7C2A8WBM9YEF
└── 48-bit timestamp ──┘└── 80 bits of randomness ──┘
Why pick ULID over UUIDv7 today? A few reasons:
- String length. 26 chars vs 36 for UUIDs. Smaller in logs, smaller in URLs, smaller in dynamoDB partition keys (where you pay per byte).
- No hyphens. Easier to double-click select, paste into shells, embed in filenames.
- Lexicographic sort = chronological sort. Plain string sort in Redis sorted sets, S3 prefixes, Cassandra clustering keys, or DynamoDB sort keys gives you time-ordered results for free.
ULID shines when the ID lives outside a relational database — log lines, S3 object names, Kafka message keys, distributed event stores. Inside Postgres, UUIDv7 is the better fit because of native support and tooling. Try our ULID generator to compare the format side by side.
Same caveat as UUIDv7: the first 10 characters reveal creation time to anyone who knows the format.
Nano ID: Short URL-Safe Identifiers Without the Drama
Nano ID is a different beast. It doesn't try to be sortable, doesn't embed a timestamp, doesn't pretend to be a UUID. It's a small, fast, URL-safe random string with configurable length and alphabet.
V1StGXR8_Z5jdHi6B-myT // default 21 chars
ckW2HF // 6 chars, custom alphabet
abc123XYZ789defGHJ // 18 chars, alphanumeric only
The default 21-character ID has roughly 126 bits of entropy — comparable to a UUIDv4. Shorter IDs trade entropy for compactness; the Nano ID collision calculator tells you how risky any given length and traffic rate is.
Reach for Nano ID when:
- You need a short share link (
yoursite.com/p/V1StGXR8_Z) - You need a public ID that shouldn't leak ordering or creation time
- You need a custom alphabet (digits only, hex only, no lowercase, etc.)
- You're making non-database identifiers — coupon codes, invite tokens, file names
Don't reach for Nano ID as a primary key in a B-tree-indexed relational database — it has the same insert fragmentation problem as UUIDv4 because the values are random. Use it for the public-facing slug and store a UUIDv7 underneath. Generate one with our Nano ID generator.
CUID2: When You're Worried About Adversaries
CUID2 is the most opinionated of the four. The CUID2 spec explicitly aims for collision resistance under adversarial conditions and unguessability — meaning you shouldn't be able to predict the next ID even if you've seen many previous ones.
k8h3z2g0nq1m7vx4d9rf2lap
A CUID2 is built from a hash of: the current timestamp, a session-specific counter, a host fingerprint, and OS-level randomness. The output is a base36 string of configurable length (default 24, max 32). Critically, the timestamp is hashed in — not exposed at the front of the string — so two CUID2s generated milliseconds apart don't appear adjacent.
Pick CUID2 when:
- IDs are user-facing and must not leak ordering (e.g. you don't want competitors scraping
/order/k8h3...to count your daily order volume) - You're running many machines generating IDs simultaneously and want strong collision resistance with no coordination
- You're okay with random-insert performance because your database isn't the bottleneck (e.g. you're using a NoSQL store or sharded SQL where this matters less — see NoSQL vs SQL for tradeoffs)
CUID2 is overkill if you don't care about timestamp leakage or ordering attacks. UUIDv7 or ULID will be simpler and faster. Try our CUID generator to see the format and compare entropy.
Database Performance: The Insert Fragmentation Story
The big practical question: which of these are safe to use as a primary key in Postgres or MySQL?
| Scheme | Sortable | Index-Friendly | Reveals Time | URL-Safe |
|---|---|---|---|---|
| UUIDv4 | No | No | No | With dashes |
| UUIDv7 | Yes | Yes | Yes | With dashes |
| ULID | Yes | Yes | Yes | Yes |
| Nano ID | No | No | No | Yes |
| CUID2 | No | No | No | Yes |
"Index-friendly" here means: monotonically increasing or close to it, so inserts append to the right side of a B-tree rather than scattering across pages. This is what determines whether your write throughput collapses at 10M+ rows on a memory-constrained instance. If you want to go deeper on why this matters, read our post on database indexes.
Rule of thumb: if the ID is going to be the clustered/primary key of a hot table, pick UUIDv7 or ULID. If the ID is for external presentation only and you store an integer or UUIDv7 underneath, Nano ID and CUID2 are great.
A small detail that adds up at scale: storage size for the same row count.
BIGINT → 8 bytes
UUIDv4/v7 → 16 bytes binary, 36 bytes as string
ULID → 16 bytes binary, 26 bytes as string
Nano ID 21 → 21 bytes
CUID2 24 → 24 bytes
Postgres and MySQL both have native UUID types that store 16 bytes. ULIDs can be stored as BYTEA or as the 16-byte binary form. The string forms are 2-3x larger and slower to compare.
For a table with 1 billion rows and 6 indexes covering the ID, the difference between BIGINT and UUID string is roughly 168 GB. That's not huge by modern standards, but it does affect cache hit rate, replication lag, and backup time.
A Quick Decision Tree
If you have to pick in 30 seconds:
- Primary key in Postgres/MySQL? UUIDv7.
- Sortable IDs across systems (Kafka, S3, Redis)? ULID.
- Short URL slug or share code? Nano ID with the length your collision math allows.
- User-facing ID where you can't leak creation time or count? CUID2.
- Auth tokens, session IDs, password reset tokens? None of these — use hashing and proper crypto primitives.
- Stuck on UUIDv4 already? Don't migrate just for fun. Migrate if your insert performance is actually a measurable problem.
Whatever you pick, make sure the choice is consistent across services. Mixing UUIDv7 and CUID2 in the same database makes joins, debugging, and tooling messier than it needs to be.
A few related patterns that look clever but cause pain in practice:
- Don't use auto-increment integers for public-facing IDs. Anyone can iterate
/user/1,/user/2to count your users. Use a UUIDv7 publicly, store an integer privately if you must. - Don't generate IDs in the application layer if your database can. Postgres
gen_random_uuid()andgen_uuid_v7()are faster and atomic with the insert. Application-side generation only makes sense for distributed coordination scenarios. - Don't shorten Nano IDs to 6 characters for public URLs at scale. Run the collision math first. At 1,000 IDs/hour, a 6-character base62 ID hits ~1% collision rate within a few months.
- Don't trust ULID/UUIDv7 timestamps for security-sensitive ordering. Clocks drift. Use a database-side sequence or transaction timestamp if you need authoritative ordering.
For a deeper look at how randomness, hashing, and encoding differ in this space, our post on encoding vs encryption vs hashing is a good companion read. Once you've picked your scheme, make sure your API responses surface IDs cleanly — the JSON Formatter and URL Encoder help when you're wiring up new endpoints and need to inspect payloads quickly.
The boring answer is usually the right one. Start with UUIDv7. Reach for ULID or Nano ID when you have a specific reason. Reach for CUID2 only if you've thought about adversaries. And when in doubt, generate a few of each and look at them — the right choice often becomes obvious once you see the format in your context.
FAQ
Should I migrate from UUIDv4 to UUIDv7?
Only if insert performance is a measurable problem. UUIDv4 fragments B-tree indexes because random inserts scatter writes across pages. If your database is under memory pressure or you're seeing slow inserts at scale, UUIDv7 is a real win. For small tables or read-heavy workloads, UUIDv4 is fine — don't migrate just for fashion.
Is ULID better than UUIDv7?
Same idea, different shape. ULID is shorter (26 chars vs 36) and uses Crockford Base32 (case-insensitive, no ambiguous characters). UUIDv7 is standardized in RFC 9562, has native database support (Postgres 18 has gen_uuid_v7()), and works with existing UUID tooling. Pick UUIDv7 inside relational DBs; pick ULID for external strings (logs, S3 keys, Kafka).
Why does Nano ID not have a timestamp?
By design — Nano ID is for short, URL-safe, unguessable identifiers where you don't want creation time leaking. Use it for share codes, invite links, file slugs. Don't use it as a primary key in a B-tree-indexed database; it has the same insert fragmentation as UUIDv4.
How short can I make a Nano ID before collisions become a problem?
Depends on traffic rate. The Nano ID collision calculator gives concrete numbers: at 1000 IDs/hour, a 10-character ID has 1% collision risk within ~10 years. Drop to 6 characters at the same rate and you hit 1% in a few months. Always do the collision math for your specific traffic before shortening.
Is CUID2 overkill for most projects?
Yes. CUID2 is designed to resist adversarial attacks where someone tries to predict the next ID — useful for sensitive systems where ID guessing matters. For typical SaaS apps where IDs are private to authenticated users, UUIDv7 or ULID are simpler and faster. Pick CUID2 only if you've identified a specific threat model.
Should I use UUIDs or auto-increment integers as primary keys?
Auto-increment if you're a single writer and can stay that way. UUIDv7 if you need to generate IDs from multiple machines, want to expose IDs publicly without leaking row counts, or anticipate future sharding. The 8 bytes vs 16 bytes per row matters at billions of rows but not millions.
Can I use ULID timestamps for "when was this created?"
Yes, and they're accurate to the millisecond. The first 48 bits of a ULID are a Unix-ms timestamp. Same for UUIDv7. But trust the timestamp only as a hint — clocks drift, and a malicious or buggy generator could produce out-of-order IDs. For authoritative timing, use a database-side created_at column.
Are these IDs cryptographically secure?
UUIDv4 and Nano ID are random enough to resist guessing (126+ bits of entropy). UUIDv7, ULID, and CUID2 contain timestamps, so the first portion is predictable — the entropy is in the random tail. None of them are designed for security tokens (session IDs, password reset URLs); for those, use crypto.randomBytes(32) and don't reach for the ID-generation libraries.