Published Jan 14, 2025
Designing the Perfect ID: Marrying UUIDv7, Stripe Prefixes, and ULID
Engineers fight about primary keys constantly. Auto-incrementing integers leak business metrics. UUIDv4 fragments database indexes. Snowflake IDs require dedicated infrastructure.
We found a middle ground. We use a split-ID strategy: UUIDv7 for the database, and a prefixed, checksummed, Base32-encoded UUIDv4 for the API.
Our public IDs look like this: u_14wrt1xnprkj3nj7wtndz74037rkw
The Database Layer (UUIDv7)
Random UUIDs (v4) are terrible primary keys for PostgreSQL. They scatter inserts across the index, which forces the database to load random disk pages for every write. This kills throughput.
UUIDv7 solves this. It embeds a timestamp in the first 48 bits, so new IDs are appended to the end of the index. You get the performance of integers with the global uniqueness of UUIDs.
We use UUIDv7 for all primary and foreign keys. It is a free performance optimization.
The Public Layer (Stripe-style Prefixes)
We do not expose UUIDv7 to users because it leaks creation time. Instead, we generate a separate random UUIDv4 for public use and format it for humans.
We use prefixes like u_ for users or p_ for projects. This idea comes from Stripe.
Prefixes solve three problems:
- Readability: You know what the ID represents immediately.
- Debugging: You cannot accidentally pass a User ID to a function expecting a Project ID.
- Polymorphism: A single API endpoint can handle different object types by checking the prefix.
The Formatting (Crockford Base32)
Hexadecimal UUIDs are hard to read. Base64 contains special characters that break URLs. We chose Crockford’s Base32.
This encoding is designed for humans:
- No ambiguity: It excludes I, L, O, and U. You never have to guess if a character is a one or an
l. - Case insensitive:
u_14WRT...works the same asu_14wrt.... - Selection friendly: Double-clicking the ID selects the whole string, unlike standard UUIDs which break at hyphens.
The Checksum
We append a 3-character checksum to the end of the ID. This adds a small amount of length but catches 99.99% of typos.
If a user swaps a character, our API rejects the request before it even hits the database. This prevents valid-but-wrong lookups and wasted database cycles.
Implementation
Our table structure is simple:
CREATE TABLE users (
-- Internal PK: UUIDv7. Fast indexing.
id secret_uuid PRIMARY KEY DEFAULT uuid_generate_v7(),
-- Public ID: UUIDv4. Random and opaque.
uuid uuid UNIQUE NOT NULL DEFAULT gen_random_uuid(),
email varchar(255) NOT NULL,
created_at timestamptz DEFAULT now()
); When the API receives an ID, we strip the prefix, verify the checksum, decode the payload, and query the uuid column. When we return data, we encode the uuid back into our custom format.
Summary
IDs are part of the user interface.
By separating the physical ID (database performance) from the logical ID (developer experience), we get fast queries and usable handles. It takes a little more setup than a standard serial ID, but it pays off in every support ticket and log entry.