Better IDs: Switching from UUID to NanoID
Why we moved from standard UUIDs to NanoIDs for primary keys: URL aesthetics, database considerations, and a custom alphabet implementation.
When I first configured NanoID as our primary key strategy, the biggest concern that kept me up at night was collision probability. We were moving away from the 128-bit safety blanket of UUIDs to an 8-character alphanumeric string, and every fiber of my database-engineer brain screamed that this was too short. I spent an entire weekend running Monte Carlo simulations, generating millions of IDs and checking for duplicates across different alphabet sizes and lengths. The math checked out for our per-tenant scoping model, but getting comfortable with the trade-off between URL aesthetics and entropy took real hands-on experimentation, not just reading the formulas.
Introduction
Unique Identifiers are the spine of any database schema. For years, the UUID (Universally Unique Identifier) has been the de facto standard. It’s robust, collision-resistant, and natively supported by PostgreSQL.
However, as we built our public-facing document sharing features, we realized that UUIDs (550e8400-e29b-41d4-a716-446655440000) are unwieldy, hard to select by double-clicking, and ugly in URLs. We switched to NanoID.
Why NanoID?
- URL Friendly: Shorter, safer characters.
- Density: Carries more information in fewer bytes.
- Customizable: We can restrict the alphabet to avoid ambiguity (e.g., confusing
land1).
What We’ll Build
In this guide, we will implement a custom ID generator. You will learn how to:
- Compare ID formats: Visual and technical differences.
- Implement Custom Alphabet: Creating
CustomId(lowercase alphanumeric). - Handle Database Integration: Storing NanoIDS efficiently in PostgreSQL.
Architecture Overview
The ID generation flow is straightforward but critical to understand:
flowchart LR
subgraph Application["🔌 Application Layer"]
Request["📥 Create Entity"]
Factory["🏭 CustomId.Generate()"]
end
subgraph Generator["🔧 NanoID Generator"]
Alphabet["🔤 Custom Alphabet\n(0-9, a-z)"]
Length["📏 8 Characters"]
Crypto["🔒 Crypto RNG"]
end
subgraph Storage["💾 PostgreSQL"]
Column["📊 CHAR(8)\nPrimary Key"]
Index["🔍 B-Tree Index"]
end
Request --> Factory
Factory --> Alphabet
Alphabet --> Length
Length --> Crypto
Crypto --> Column
Column --> Index
classDef primary fill:#7c3aed,color:#fff
classDef secondary fill:#06b6d4,color:#fff
classDef db fill:#f43f5e,color:#fff
classDef warning fill:#fbbf24,color:#000
class Application primary
class Generator secondary
class Storage db
Key Components:
- Factory Method:
CustomId.Generate()encapsulates ID creation - Custom Alphabet: 36 characters (lowercase + digits) for URL-friendliness
- Cryptographic RNG: Ensures unpredictable, secure IDs
- Fixed-Width Storage:
CHAR(8)optimizes PostgreSQL indexing
Visual Comparison
Let’s look at the difference in a real-world URL context.
flowchart LR
subgraph UUID ["UUID (Standard)"]
U["/docs/550e8400-e29b-41d4-a716-446655440000"]
end
subgraph NanoID ["NanoID (Custom)"]
N["/docs/abc12345"]
end
UUID --> NanoID
classDef primary fill:#7c3aed,color:#fff
classDef secondary fill:#06b6d4,color:#fff
classDef db fill:#f43f5e,color:#fff
classDef warning fill:#fbbf24,color:#000
class UUID db
class NanoID primary
The NanoID is significantly more compact while retaining sufficient entropy for our scale.
Section 1: The NanoID Standard vs. Request
Standard NanoID uses A-Za-z0-9_-. This is great, but it includes case sensitivity (a vs A) and special characters (-, _).
For our project, we wanted lowercase alphanumeric only for maximum readability and ease of typing on mobile devices.
Custom Alphabet Implementation
We use the NanoId library in .NET but configure a custom alphabet.
using NanoidDotNet;
public static class CustomId
{
// Removing look-alikes if desired, but here we use simple lowercase alpha-num
private const string Alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
private const int Length = 8; // Short, yet collision resistant enough for per-user scope
public static string Generate()
{
return Nanoid.Generate(Alphabet, Length);
}
}
You can verify collision probability for your own parameters using the NanoID collision calculator.
[NanoID Collision Calculator] — Aleksandr Zhuravlev , 2024-01-20Section 2: Database Storage
In PostgreSQL, a UUID is stored as a 128-bit integer (uuid type). Ideally, we might want to store our ID as text, or char(8).
CREATE TABLE documents (
-- Fixed length is more efficient than TEXT
id CHAR(8) PRIMARY KEY,
-- Owner establishes the scope for collision checking
owner_id CHAR(8) REFERENCES users(id),
title TEXT NOT NULL
);
Performance Consideration
Since NanoIDs are random strings, inserting them into a B-Tree index (like a Primary Key) can cause fragmentation compared to sequential IDs (like UUIDv7 or Integers).
However, CustomId is primarily used for lookup, not sorting. The cost of random insertion is a trade-off we accept for UX. For strictly internal, high-volume logging tables, we stick to UUIDv7 or BIGINT.
Conclusion
Switching to NanoID gave our application a more polished feel. URLs are cleaner, shareable, and less intimidating to non-technical users. Looking back, the decision was less about raw technical superiority and more about user experience — shorter, readable IDs make every shareable link feel intentional rather than machine-generated. The key insight is that not every table needs the same ID strategy: user-facing resources benefit from compact NanoIDs, while internal high-volume tables are better served by sequential identifiers like UUIDv7.