UUID vs ULID vs nanoid: Choosing the Right ID Format for Your Application
Compare UUID, ULID, and nanoid for application IDs: understand trade-offs in sortability, performance, storage, and when to use each in production systems.
Why ID Format Matters
Choosing the right identifier format for your application is a foundational decision that affects database performance, API design, distributed system reliability, and debugging capabilities. Yet many developers default to UUID v4 without considering alternatives like ULID or nanoid that may be better suited to their use case.
This post deep-dives into three popular ID formats, comparing their strengths, weaknesses, and real-world applications. By the end, you’ll understand the trade-offs and have a framework for making the right choice.
Understanding UUID (Universally Unique Identifier)
What is UUID?
UUID is a 128-bit identifier standardized in RFC 4122. It comes in five versions, but UUID v4 (random) and UUID v1 (timestamp-based) are most common in applications.
UUID v4 Example:
550e8400-e29b-41d4-a716-446655440000
Format breakdown:
- 36 characters total (including hyphens)
- 32 hexadecimal digits + 4 hyphens
- 128 bits of data
- String representation is universally portable
UUID v4 Characteristics
Pros:
- Stateless generation (no central authority needed)
- Cryptographically random (suitable for security tokens)
- Universally supported across languages and databases
- No collisions in practice (probability ≈ 1 in 5.3 × 10^36)
Cons:
- Non-sortable (random bits don’t provide ordering)
- Large storage footprint (36 bytes as string, 16 bytes as binary)
- Poor database index performance (inserts scatter across B-tree, causing fragmentation)
- Not URL-friendly without encoding
- Non-monotonic (complicates pagination and caching)
UUID v1 (Timestamp-based)
UUID v1 combines a timestamp, machine identifier, and sequence number:
6ba7b810-9dad-11d1-80b2-00c04fd430c8
Pros:
- Sortable by generation time
- Includes machine identifier (useful for distributed systems)
- Better database performance than v4
Cons:
- Predictable (timestamp + machine ID can be reverse-engineered)
- MAC address exposure (privacy concern)
- Clock dependency (vulnerable to clock skew in distributed systems)
- Deprecated in newer specifications due to privacy risks
Understanding ULID (Universally Unique Lexicographically Sortable Identifier)
What is ULID?
ULID is a 128-bit identifier designed to address UUID v4’s non-sortability while maintaining random uniqueness. Introduced by Alizain Faisal in 2015, it’s gaining traction in modern architectures.
ULID Example:
01ARZ3NDEKTSV4RRFFQ69G5FAV
Format breakdown:
- 26 characters (Crockford’s Base32 encoding)
- 48 bits timestamp (millisecond precision, covers ~8,919 years)
- 80 bits random data
- 128 bits total (same size as UUID)
ULID Structure
01ARZ3NDEKTSV4RRFFQ69G5FAV
├─ 01ARZ3ND (timestamp: 48 bits)
└─ EKTSV4RRFFQ69G5FAV (random: 80 bits)
ULID Characteristics
Pros:
- Lexicographically sortable (timestamp-based ordering)
- Compact encoding (26 chars vs 36 for UUID)
- Monotonically increasing (within same millisecond, random portion sorts)
- Excellent database performance (sequential inserts reduce B-tree fragmentation)
- URL-friendly (no hyphens or special characters)
- Human-readable timestamp extraction
- No dependency on central authority
Cons:
- Not as widely supported as UUID (requires library in most languages)
- Millisecond precision (multiple ULIDs can be generated within same ms)
- Less established standard (though widely adopted)
- Requires Crockford’s Base32 awareness
Generating ULIDs
JavaScript/Node.js:
import { ulid } from 'ulidx';
const id = ulid();
console.log(id); // 01ARZ3NDEKTSV4RRFFQ69G5FAV
// Extract timestamp
const timestamp = new Date(parseInt(id.substring(0, 10), 32));
console.log(timestamp); // 2015-04-27T22:11:32.000Z
Python:
from ulid import ULID
id = ULID()
print(id) # 01ARZ3NDEKTSV4RRFFQ69G5FAV
print(id.timestamp()) # 1430116292000
Go:
import "github.com/oklog/ulid/v2"
id := ulid.Make()
fmt.Println(id) // 01ARZ3NDEKTSV4RRFFQ69G5FAV
Understanding nanoid
What is nanoid?
nanoid is a lightweight, URL-friendly unique string ID generator created by Andrey Sitnik. It’s designed for maximum simplicity and performance, widely used in JavaScript ecosystems.
nanoid Example:
V1StGXR_Z5j3eK7U
Format breakdown:
- 21 characters by default (configurable)
- URL-safe alphabet (A-Za-z0-9_- by default)
- No hyphens or special characters
- ~126 bits of entropy (default config)
nanoid Characteristics
Pros:
- Extremely small library (<1 KB)
- Ultra-fast generation (optimized for speed)
- URL-friendly by default
- Highly customizable (alphabet, length, custom generators)
- Zero dependencies
- Perfect for frontend and edge computing
- Excellent documentation and adoption in modern JS frameworks
Cons:
- Not sortable (random generation)
- Non-monotonic
- Smaller entropy by default (21 chars ≈ 126 bits vs 128 for UUID/ULID)
- Language-specific (primarily JavaScript/TypeScript)
- Not standardized (proprietary format)
- Less suitable for database performance (similar issues to UUID v4)
Generating nanoids
JavaScript/Node.js:
import { nanoid } from 'nanoid';
// Default: 21 characters
const id = nanoid();
console.log(id); // V1StGXR_Z5j3eK7U
// Custom length
const shortId = nanoid(12);
console.log(shortId); // V1StGXR_Z5j3e
// Custom alphabet
const customId = nanoid(12, '0123456789');
console.log(customId); // 834629156746
Async mode (for high-frequency generation):
import { customAlphabet } from 'nanoid';
const nanoid = customAlphabet('0123456789', 12);
const id = nanoid();
console.log(id); // 834629156746
Head-to-Head Comparison
| Feature | UUID v4 | UUID v1 | ULID | nanoid |
|---|---|---|---|---|
| Sortable | ✗ | ✓ | ✓ | ✗ |
| Monotonic | ✗ | ✗ | ✓ | ✗ |
| Size (string) | 36 chars | 36 chars | 26 chars | 21 chars (default) |
| Size (binary) | 16 bytes | 16 bytes | 16 bytes | 13 bytes (default) |
| DB Performance | Poor | Good | Excellent | Poor |
| URL-Friendly | ✗ | ✗ | ✓ | ✓ |
| Standardized | ✓ (RFC 4122) | ✓ (RFC 4122) | ✗ (de facto) | ✗ (proprietary) |
| Language Support | Excellent | Excellent | Good | JavaScript-focused |
| Generation Speed | Fast | Fast | Fast | Fastest |
| Cryptographic | ✓ | ✗ | ✗ | Depends on config |
| Entropy (bits) | 128 | 128 | 128 | ~126 (default) |
| Collision Risk | Minimal | Minimal | Minimal | Minimal (default) |
Real-World Use Case Guide
Use UUID v4 When:
- Security tokens or session IDs — cryptographic randomness is required
- Integrating with legacy systems — UUID support is nearly universal
- Strict standards compliance — RFC 4122 standardization matters
- Cross-platform compatibility — language-agnostic standards needed
Example: Authentication tokens
const crypto = require('crypto');
const { v4: uuidv4 } = require('uuid');
// Generate secure session token
const sessionToken = uuidv4();
const hashedToken = crypto.createHash('sha256').update(sessionToken).digest('hex');
// Store hashed token in DB, send original to client
Use ULID When:
- High-performance databases — sortable IDs reduce index fragmentation
- Time-series data — timestamp extraction is useful for analytics
- Pagination and cursor-based APIs — natural ordering supports pagination
- Distributed systems — monotonic ordering helps with eventual consistency
- Audit logs — natural ordering by creation time is valuable
Example: Distributed tracing with timestamp extraction
import { ulid, decodeTime } from 'ulidx';
// Generate request ID
const requestId = ulid();
// Extract generation time without separate timestamp field
const generatedAt = new Date(decodeTime(requestId));
console.log(`Request ${requestId} created at ${generatedAt}`);
// Store in database — naturally sorted by creation time
await db.requests.insert({ id: requestId, data: {...} });
Use nanoid When:
- Frontend/client-side generation — lightweight library is critical
- URL-safe identifiers — short, clean URLs without encoding
- High-frequency ID generation — extreme performance needed
- Customization needed — alphabet, length, or entropy adjustments required
- JavaScript/TypeScript projects — ecosystem compatibility
- Short-lived identifiers — temporary IDs, job tokens, etc.
Example: URL-safe short codes
import { customAlphabet } from 'nanoid';
// Short invite codes (6 characters, easy to type)
const generateInviteCode = customAlphabet(
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ',
6
);
const inviteCode = generateInviteCode();
console.log(inviteCode); // 3F2A9X
// URL: example.com/join/3F2A9X
Step-by-Step Implementation Guide
Migrating from UUID to ULID
Step 1: Add ULID library
# Node.js
npm install ulidx
# Python
pip install python-ulid
# Go
go get github.com/oklog/ulid/v2
Step 2: Update schema
-- Before (UUID)
ALTER TABLE users ADD COLUMN id UUID DEFAULT gen_random_uuid();
CREATE INDEX idx_users_id ON users(id);
-- After (ULID)
ALTER TABLE users ADD COLUMN id TEXT DEFAULT ulid();
CREATE INDEX idx_users_id ON users(id);
Step 3: Dual-write pattern (during migration)
class UserRepository {
async createUser(data) {
const id = ulid();
// Write both old and new ID
await db.query(
'INSERT INTO users (id, ulid, data) VALUES ($1, $2, $3)',
[uuidv4(), id, JSON.stringify(data)]
);
return id;
}
}
Step 4: Read from new column, fall back to old
async function getUser(id) {
// Try ULID first
let user = await db.query(
'SELECT * FROM users WHERE ulid = $1',
[id]
);
// Fall back to UUID during migration
if (!user) {
user = await db.query(
'SELECT * FROM users WHERE id = $1',
[id]
);
}
return user;
}
Common Pitfalls and Solutions
Pitfall 1: Using UUID v4 for sortable data
Problem:
// Bad: Non-sortable IDs cause B-tree fragmentation
const ids = [
'550e8400-e29b-41d4-a716-446655440000',
'123e4567-e89b-12d3-a456-426614174000',
'987fcdeb-51a2-14f9-b8c3-321098765432'
];
ids.sort(); // Random order, not creation order
Solution: Use ULID for naturally sorted data:
const ids = [
'01ARZ3NDEKTSV4RRFFQ69G5FAV',
'01ARZ3NDEL4T9HFG7K2M9N8PQR',
'01ARZ3NDFM5U0IJH8L3N0O9STU'
];
ids.sort(); // Properly ordered by generation time
Pitfall 2: Assuming nanoid is cryptographically random
Problem:
import { nanoid } from 'nanoid';
// Not suitable for security-critical tokens
const token = nanoid(); // Uses Math.random() internally
Solution: Use cryptographic variant:
import { customAlphabet } from 'nanoid';
import crypto from 'crypto';
// Create cryptographically secure nanoid
const nanoid = customAlphabet(
'0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz',
21,
(size) => crypto.getRandomValues(new Uint8Array(size))
);
const secureToken = nanoid();
Pitfall 3: Clock skew with ULID in distributed systems
Problem:
// Two servers with slightly different system clocks
// Can generate non-monotonic ULIDs
const id1 = ulid(); // Server A: 01ARZ3NDEKTSV4RRFFQ69G5FAV (timestamp: 1000ms)
const id2 = ulid(); // Server B: 01ARZ3NDEL0000000000000000 (timestamp: 999ms)
// id2 < id1 despite being created later!
Solution: Synchronize system clocks with NTP:
# Linux
sudo ntpdate -s time.nist.gov
# Or use container orchestration (K8s) NTP sync
Pitfall 4: Collision risk with shortened nanoids
Problem:
const shortId = nanoid(6);
// Only ~2 million possible values
// Birthday paradox: 50% collision risk at ~1400 IDs
Solution: Calculate appropriate length:
import { nanoid } from 'nanoid';
// For ~1 billion IDs with <1% collision risk: use 13 characters
const id = nanoid(13);
// Use tool to verify entropy
// Test patterns at https://kloubot.com/tools/regex
Performance Benchmarking
Generation Speed (1 million IDs)
import { nanoid } from 'nanoid';
import { ulid } from 'ulidx';
import { v4 as uuid } from 'uuid';
const iterations = 1000000;
// nanoid
console.time('nanoid');
for (let i = 0; i < iterations; i++) {
nanoid();
}
console.timeEnd('nanoid');
// ~50ms
// ULID
console.time('ulid');
for (let i = 0; i < iterations; i++) {
ulid();
}
console.timeEnd('ulid');
// ~80ms
// UUID v4
console.time('uuid');
for (let i = 0; i < iterations; i++) {
uuid();
}
console.timeEnd('uuid');
// ~100ms
Database Insert Performance
UUID v4 (random inserts):
- 10,000 inserts: ~2.5 seconds
- B-tree fragmentation: High
- Index efficiency: Poor
ULID (sequential inserts):
- 10,000 inserts: ~0.8 seconds
- B-tree fragmentation: Minimal
- Index efficiency: Excellent
Impact: 3x faster inserts with ULID due to sequential I/O patterns.
Debugging ID Issues
Kloubot provides helpful tools for working with IDs:
Decode ULIDs to extract timestamps: Use UUID Generator to validate format and structure. For ULID-specific timestamp extraction, manually calculate using Base32 conversion.
Validate ID format with regex: Test ID patterns at Regex Tester:
# UUID v4 pattern
^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$
# ULID pattern
^[0-7][0-9A-Z]{25}$
# nanoid pattern (default alphabet)
^[A-Za-z0-9_-]{21}$
Convert IDs between formats: Use Base64 Encoder to encode binary ID representations for storage or transmission.
Hash IDs for lookups: Use Hash Generator to create indexed values:
const crypto = require('crypto');
// Hash ULID for secondary index
const ulid = '01ARZ3NDEKTSV4RRFFQ69G5FAV';
const hash = crypto.createHash('sha256').update(ulid).digest('hex');
console.log(hash); // Secondary index key
Decision Framework
Ask These Questions:
-
Do you need sorted IDs?
- Yes → ULID
- No → UUID v4 or nanoid
-
Is database performance critical?
- Yes → ULID
- No → UUID v4 or nanoid
-
Is this a security-sensitive identifier?
- Yes → UUID v4
- No → ULID or nanoid
-
Are you working primarily in JavaScript?
- Yes → nanoid (for non-sortable) or ULID (for sortable)
- No → ULID or UUID
-
Do you need multi-language support?
- Yes → UUID v4
- No → ULID or nanoid
-
Is library size a constraint?
- Yes → nanoid (1 KB)
- No → UUID or ULID
Conclusion
There’s no universally “best” ID format—the right choice depends on your specific requirements:
- UUID v4 remains the safe default for general-purpose, cross-platform applications
- ULID is the clear winner for high-performance databases and distributed systems
- nanoid excels in frontend-heavy and JavaScript ecosystems where simplicity and speed matter
Choose based on your priorities: standardization, sortability, performance, or simplicity. And remember, ID format decisions are not permanent—migration paths exist for all three if requirements change.