I used to slap uuid() on everything and call it a day. Primary keys, API tokens, file names, session IDs — all UUID v4. It worked, but I was unknowingly creating performance problems that didn’t surface until the database grew.
If you’re generating IDs in your application, the choice between UUID, NanoID, and ULID actually matters more than you’d think. Here’s what I’ve learned.
The Three Contenders
| Feature | UUID v4 | NanoID | ULID |
|---|---|---|---|
| Format | 550e8400-e29b-41d4-a716-446655440000 | V1StGXR8_Z5jdHi6B-myT | 01ARZ3NDEKTSV4RRFFQ69G5FAV |
| Length | 36 chars (with hyphens) | 21 chars (default) | 26 chars |
| Sortable | No | No | Yes (timestamp-based) |
| Collision risk | ~1 in 2^122 | ~1 in 2^126 (default) | ~1 in 2^80 per millisecond |
| URL-safe | No (hyphens) | Yes | Yes |
| Database index performance | Poor (random) | Poor (random) | Good (sequential) |
| Spec/Standard | RFC 4122 | No formal spec | Formal spec |
| Dependencies | Built-in (most languages) | npm package (tiny) | npm package |
Why UUID v4 Isn’t Always the Answer
UUID v4 is the default choice for most developers, and for good reason — it’s built into practically every language and framework. But it has two significant drawbacks that only show up at scale.
Problem 1: Random UUIDs Destroy Index Performance
When you use UUID v4 as a primary key in a B-tree indexed database (PostgreSQL, MySQL, SQLite), every new insert lands at a random position in the index. This causes:
- Page splits — the database constantly reorganizes index pages
- Cache misses — recently written rows aren’t near each other in memory
- Write amplification — more disk I/O per insert than necessary
I noticed this firsthand when a table hit around 5 million rows. Insert times had gradually increased by about 40%, and the database was doing significantly more I/O than expected. Switching the primary key to ULID (which is time-sorted) brought insert performance back to normal.
Problem 2: UUIDs Are Wasteful in URLs
https://api.example.com/users/550e8400-e29b-41d4-a716-446655440000
That’s 36 characters of ID in your URL. Compare with NanoID:
https://api.example.com/users/V1StGXR8_Z5jdHi6B-myT
21 characters, URL-safe by default, no hyphens to worry about. In high-traffic APIs, those extra bytes add up across millions of requests.
NanoID: When Size and Speed Matter
NanoID was designed to be a smaller, faster, URL-friendly alternative to UUID. It’s 126 bits of randomness packed into 21 characters using a URL-safe alphabet (A-Za-z0-9_-).
import { nanoid } from 'nanoid';
const id = nanoid(); // "V1StGXR8_Z5jdHi6B-myT"
const short = nanoid(10); // "IRFa-VaY2b"
Where NanoID shines:
- Short URLs and slugs
- Client-side generated IDs (smaller payload)
- Session tokens and temporary identifiers
- When you need a custom alphabet or length
The common mistake with NanoID:
Developers sometimes reduce the length for aesthetics without understanding the collision math:
// DON'T DO THIS for primary keys
const id = nanoid(8); // Only 48 bits of entropy!
An 8-character NanoID has roughly 2^48 possible values. That sounds like a lot until you consider the birthday problem — with just ~17 million IDs, you have a 1% chance of collision. For a primary key, that’s unacceptable.
Rule of thumb: Never go below 21 characters for NanoID if uniqueness matters. If you need shorter IDs, use them as display identifiers, not primary keys.
ULID: The Best of Both Worlds?
ULID (Universally Unique Lexicographically Sortable Identifier) combines a 48-bit timestamp with 80 bits of randomness:
01ARZ3NDEKTSV4RRFFQ69G5FAV
|----------|----------------|
Timestamp Randomness
(48 bits) (80 bits)
This gives you two crucial properties that UUID v4 lacks:
- Sortability — ULIDs generated later are always lexicographically greater
- Timestamp extraction — you can derive when the ID was created
import { ulid, decodeTime } from 'ulid';
const id = ulid(); // "01ARZ3NDEKTSV4RRFFQ69G5FAV"
const timestamp = decodeTime(id); // 1469918176385
Why Sortability Matters for Databases
When IDs are time-sorted, new inserts always go to the end of the B-tree index. This means:
- No page splits
- Hot data stays in cache
- Sequential disk writes (much faster)
ORDER BY idgives you chronological order for free
I now use ULID for any table that will grow beyond a few hundred thousand rows. The performance difference is measurable and consistent.
The ULID Gotcha: Monotonicity
Within the same millisecond, ULID’s randomness component should be monotonically increasing to maintain sort order. Not all implementations handle this correctly.
// Good implementation: monotonic ULID
import { monotonicFactory } from 'ulid';
const ulid = monotonicFactory();
// These will be correctly ordered even within the same ms
const id1 = ulid(); // 01BX5ZZKBKACTAV9WEVGEMMVRY
const id2 = ulid(); // 01BX5ZZKBKACTAV9WEVGEMMVRZ (incremented)
const id3 = ulid(); // 01BX5ZZKBKACTAV9WEVGEMMVS0 (incremented)
If your ULID library doesn’t support monotonic generation, IDs created in the same millisecond will have random ordering — which partially defeats the purpose. Always use the monotonic variant for database keys.
Common Mistakes I’ve Seen (and Made)
Mistake 1: Using UUIDs as URL Slugs
/blog/550e8400-e29b-41d4-a716-446655440000
UUIDs are ugly in URLs, hard to read over the phone, and waste characters. Use NanoID for URL-facing identifiers, or even better, use meaningful slugs with a short random suffix:
/blog/json-vs-yaml-config-mistakes-xK9f2
Mistake 2: Exposing Timestamps in ULIDs to Users
Since anyone can extract the creation timestamp from a ULID, don’t use them as user-facing IDs if the creation time is sensitive information. For example, if a ULID is used as an order ID, competitors could estimate your order volume by comparing timestamps.
Use ULID for internal primary keys and NanoID for external-facing identifiers.
Mistake 3: Storing UUIDs as Strings
If you’re using PostgreSQL, always use the native UUID type, not VARCHAR(36):
-- Wrong: wastes space and is slower to compare
CREATE TABLE users (
id VARCHAR(36) PRIMARY KEY
);
-- Right: stored as 16 bytes, indexed efficiently
CREATE TABLE users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid()
);
A UUID column takes 16 bytes. A VARCHAR(36) takes 37+ bytes and requires string comparison instead of binary comparison. Over millions of rows, this difference is substantial.
Mistake 4: Not Considering Your Database’s Native Support
- PostgreSQL has native
UUIDtype andgen_random_uuid()— use them - MySQL 8+ has
UUID_TO_BIN()andBIN_TO_UUID()for efficient storage - MongoDB uses
ObjectIdby default, which is already time-sortable (similar philosophy to ULID) - SQLite has no native UUID type — store as
BLOB(16)orTEXT
Don’t fight your database. If it has built-in ID generation that fits your needs, use it.
My Recommendation
After trying all three across different projects, here’s what I reach for:
| Use Case | My Choice | Why |
|---|---|---|
| Database primary key (high volume) | ULID | Sortable, great index performance |
| Database primary key (low volume) | UUID v4 | Built-in, universally understood |
| URL identifiers | NanoID (21 chars) | Short, URL-safe, no dependencies |
| API tokens / session IDs | NanoID (32+ chars) | Configurable entropy |
| Distributed systems | UUID v7 (if available) | Standardized, time-sorted |
| Quick prototyping | UUID v4 | Zero setup |
One thing worth mentioning: UUID v7 is the new kid on the block. It’s essentially what ULID does (timestamp + random), but as an official RFC standard. If your language/framework supports it, UUID v7 might be the future default that makes this entire comparison obsolete.
Generate and Test
If you want to experiment with UUIDs, check out our UUID Generator — it supports different versions, custom separators, and bulk generation so you can see the format differences firsthand. For generating secure random strings for tokens, the Password Generator lets you control length, character sets, and entropy.
Part of our Developer Generators Guide series. For securing your generated tokens, read Password Security: What Every Developer Should Know.