Backend Intermediate 15 min

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.

By Victor Robin Updated:

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.

[RFC 9562 - Universally Unique IDentifiers (UUIDs)] — IETF , 2024-05-01

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.

[NanoID: A tiny, secure, URL-friendly unique string ID generator] — Andrey Sitnik , 2024-06-15

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 l and 1).

What We’ll Build

In this guide, we will implement a custom ID generator. You will learn how to:

  1. Compare ID formats: Visual and technical differences.
  2. Implement Custom Alphabet: Creating CustomId (lowercase alphanumeric).
  3. 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.

[NanoidDotNet - .NET implementation of NanoID] — codeyu , 2024-03-10
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-20

Section 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).

[PostgreSQL B-Tree Index] — PostgreSQL Documentation , 2024-11-01

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.

[UUIDv7 for Better Database Performance] — Cybertec PostgreSQL , 2024-08-12

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.

Next Steps

Further Reading

[NanoID README and collision probability analysis] — GitHub Community , 2024 [IETF RFC 9562 on UUID formats including UUIDv7] — IETF , 2024 [PostgreSQL documentation on index types and B-tree internals] — PostgreSQL Global Development Group , 2024 [NanoID collision calculator for custom alphabet configurations] — Anton Yelizarov , 2024