Using Prefixs for Human-Readable UUIDs
A raw UUID does its job well as a unique identifier, but it does nothing to help the human sitting in front of a terminal or a support ticket. Given a bare string like a3f1c9e2-7b44-4d8a-9c1e-2f6b8d4a0e11, there's no way to tell at a glance whether you're looking at a user, an invoice, a webhook event, or a database migration artifact. That ambiguity is exactly what prefixed identifiers solve, and it's why you'll find the pattern baked into the API design of Stripe, GitHub, Slack, and a growing number of internal platforms.
This post walks through the mechanics of prefixed UUIDs: how the format is constructed, how to generate and validate them in a few common stacks, where the prefix should actually live in your storage layer, and how the approach stacks up against plain UUIDs, ULIDs, and typed integer keys.
What a Prefixed UUID Actually Is
A prefixed UUID is a display and transport format that wraps a standard UUID (or another sufficiently random identifier) with a short, human-readable tag identifying its type or origin, separated by an underscore. The general shape is:
{prefix}_{identifier}
For example:
usr_a3f1c9e27b444d8a9c1e2f6b8d4a0e11
inv_01h2xz2q8p3f9k7m6n5j4h3g2f1e0d9c
whk_7f3e9b2c1a4d5e6f8091827364554637
The prefix is typically two to four lowercase alphanumeric characters that abbreviate the entity, table, or data source the identifier belongs to: usr for users, inv for invoices, whk for webhook events, ord for orders. The underscore is a deliberate delimiter choice, since it's unambiguous in URLs, log lines, shell commands, and most tokenization or word-boundary logic in code editors, unlike a hyphen, which UUIDs already use internally and which some tools treat as a word-break character during double-click selection.
Underneath the prefix, the identifier itself is still doing the actual uniqueness work. The prefix is metadata glued onto the front for human and tooling benefit; it is not a substitute for entropy.
Why Bother: The Three Concrete Benefits
Readability in logs, tickets, and debugging sessions. When an engineer is scanning a log stream or a support ticket references an ID, inv_7f3e9b2c... immediately tells you what kind of record you're dealing with, without a database lookup. This sounds like a minor convenience until you're three hours into an incident and every second spent context-switching between "what table is this row from" and "what's actually broken" matters.
Guarding against accidental cross-entity misuse. This is the underrated benefit. Two UUIDs are, by design, indistinguishable from each other at the string level. If a developer copies a customer_id into a field that expects an order_id, nothing about a bare UUID will catch that mistake until a query returns zero rows or, worse, silently succeeds against the wrong row in an environment where IDs aren't scoped per table. A prefix turns that class of bug into something a code reviewer, a log line, or a runtime assertion can catch immediately, because usr_... showing up where ord_... is expected is an obvious mismatch rather than an invisible one.
Data source or table attribution. In systems that shard by entity type, aggregate data from multiple services, or expose IDs across a public API surface, the prefix documents provenance directly in the identifier. You don't need a lookup table or a naming convention buried in a wiki page to know that whk_ came from the webhook ingestion service.
Mechanics: What Goes Where
The core design decision is this: does the prefix live in the database, or only at the application and API boundary? Both approaches are used in production systems, and the right answer depends on your query patterns.
Option A: Store the Full Prefixed String
The column stores the literal usr_a3f1c9e2... text value. Simplicity is the main advantage: whatever you log, whatever you query, whatever you see in a database client is exactly what your API returns. The cost is that you're now storing and indexing a longer string than a native UUID type, and most relational databases don't have a native column type for "UUID with a tag," so you typically end up in VARCHAR or TEXT territory with string comparison semantics instead of the more compact fixed-width comparison a native UUID type gives you.
Option B: Store a Native UUID, Apply the Prefix at the Application Layer
The database column stays a native UUID (Postgres) or BINARY(16) (MySQL), storing only the identifier's underlying bits. The prefix is derived from the table or type and applied when serializing to JSON, logs, or API responses, and stripped back off when parsing incoming requests. This keeps your storage compact and your indexes fast, since you're still comparing raw 128-bit values, and it means the prefix vocabulary can evolve without a data migration.
The tradeoff is a small amount of application-layer plumbing: every entry and exit point (API serializers, ORM hooks, log formatters) needs to know how to add and strip the prefix consistently, and ad hoc database queries run directly against the table won't show the friendly form unless you build that into your tooling.
In practice, most teams that adopt this pattern seriously land on Option B for high-volume tables and Option A for smaller reference or configuration tables where the storage and index overhead is negligible. Neither is universally correct; it's a genuine tradeoff between storage/index efficiency and the simplicity of "what you store is what you see."
Encoding the Identifier Portion
The part after the underscore doesn't have to be a hyphenated, hex-encoded UUID. A few common choices:
- Hex UUID, hyphens stripped:
a3f1c9e27b444d8a9c1e2f6b8d4a0e11. Simple, familiar, but 32 characters is on the long side. - Base32 (Crockford variant): shorter, case-insensitive, avoids visually ambiguous characters like
0/Oand1/I/l. This is what ULID uses for its 26-character representation. - Base62 (0-9, a-z, A-Z): the most compact common option, since it packs more entropy per character than hex or Base32. A 128-bit value comes out to roughly 22 characters in Base62.
Whichever encoding you choose, keep it consistent across your system, and pick one that's URL-safe without additional escaping if these identifiers are going to show up in paths or query strings.
Implementation Examples
C#: A Typed, Prefix-Validated Identifier
A strongly typed wrapper around Guid gives you compile-time protection against passing the wrong kind of ID into the wrong method, on top of the human-readable benefit.
public readonly struct InvoiceId : IEquatable<InvoiceId>
{
private const string Prefix = "inv";
public Guid Value { get; }
private InvoiceId(Guid value) => Value = value;
public static InvoiceId NewId() => new(Guid.NewGuid());
public static InvoiceId Parse(string input)
{
if (!input.StartsWith(Prefix + "_", StringComparison.Ordinal))
throw new FormatException($"Expected prefix '{Prefix}_' but got '{input}'.");
var raw = input[(Prefix.Length + 1)..].Replace("-", "");
if (!Guid.TryParseExact(raw, "N", out var guid))
throw new FormatException($"'{input}' is not a valid {nameof(InvoiceId)}.");
return new InvoiceId(guid);
}
public override string ToString() => $"{Prefix}_{Value:N}";
public bool Equals(InvoiceId other) => Value.Equals(other.Value);
public override bool Equals(object? obj) => obj is InvoiceId other && Equals(other);
public override int GetHashCode() => Value.GetHashCode();
}
Because InvoiceId and, say, CustomerId would be distinct types rather than both being bare Guid, the compiler stops you from accidentally passing one where the other is expected, which is a stronger guarantee than the string prefix alone provides.
SQL: Postgres, Native UUID with an Application-Facing View
This keeps storage compact while giving anyone querying the database directly a readable option, if you're willing to accept the extra view layer.
create table invoices (
id uuid primary key default gen_random_uuid(),
customer_id uuid not null references customers (id),
amount_cents integer not null,
created_at timestamptz not null default now()
);
create view invoices_readable as
select
'inv_' || replace(id::text, '-', '') as id,
'cus_' || replace(customer_id::text, '-', '') as customer_id,
amount_cents,
created_at
from invoices;
Application code reads and writes against the native uuid columns for performance; the view exists purely as a debugging and ad hoc query convenience.
JavaScript / Node: Generation and Validation
import { randomUUID } from 'crypto';
const PREFIX_PATTERN = /^([a-z]{2,4})_([0-9a-f]{32})$/;
function generatePrefixedId(prefix) {
const raw = randomUUID().replace(/-/g, '');
return `${prefix}_${raw}`;
}
function parsePrefixedId(value, expectedPrefix) {
const match = PREFIX_PATTERN.exec(value);
if (!match) {
throw new Error(`'${value}' is not a valid prefixed identifier.`);
}
const [, prefix, raw] = match;
if (expectedPrefix && prefix !== expectedPrefix) {
throw new Error(`Expected prefix '${expectedPrefix}' but got '${prefix}'.`);
}
return raw;
}
const invoiceId = generatePrefixedId('inv');
// inv_a3f1c9e27b444d8a9c1e2f6b8d4a0e11
parsePrefixedId(invoiceId, 'inv'); // succeeds
parsePrefixedId(invoiceId, 'usr'); // throws
Runtime validation like this is the JavaScript equivalent of the C# type system doing the work for you: it won't stop a bad value from being constructed, but it will stop it from being silently accepted somewhere downstream.
Comparison with Alternatives
| Approach | Human-readable | Type-safety at a glance | Sortable | Storage size (native) | Collision resistance |
|---|---|---|---|---|---|
| Raw UUID v4 | No | No | No | 16 bytes | Very high (122 random bits) |
| Raw UUID v7 | No | No | Yes (time-ordered) | 16 bytes | Very high (74 random bits) |
| ULID | Partially (fixed length helps) | No | Yes | 16 bytes | Very high (80 random bits) |
| Prefixed UUID (app-layer prefix) | Yes | Yes, by convention | Depends on underlying version | 16 bytes | Same as underlying UUID version |
| Prefixed UUID (stored as text) | Yes | Yes, by convention | Depends on underlying version | ~36+ bytes (prefix + text encoding) | Same as underlying UUID version |
| Typed auto-increment integer + prefix | Yes | Yes, by convention | Yes (naturally) | 4-8 bytes | None on its own; relies on being non-public |
A few things stand out from this table. Prefixing doesn't cost you any entropy or collision resistance, since it's additive metadata on top of whatever identifier you're already generating; the underlying UUID version still determines your actual uniqueness guarantees. Sortability is inherited from the identifier you choose to prefix, not from the prefix itself, so pairing a prefix with UUID v7 or a ULID gets you both readability and index-friendly ordering, while pairing it with UUID v4 gets you readability without the sort benefit. And storing the prefix as literal text does carry a real, measurable storage and index cost at scale, which is the main argument for keeping prefixes at the application layer on high-volume tables.
Typed auto-increment integers with a prefix, like the classic usr_4821 pattern some older systems use, are worth a mention too. They're compact and naturally sortable, but the sequential integer underneath is guessable, which reintroduces the enumeration risk that UUIDs were adopted to avoid in the first place. If you go this route for public-facing IDs, you're trading uniqueness guarantees for compactness, and you'll want a real authorization check on every lookup regardless, which is good practice either way.
Practical Pitfalls to Avoid
Prefix collisions across teams. If prefixes aren't centrally registered somewhere, two teams will eventually pick evt for two different entities. A short, documented registry of prefixes, even just a markdown table in your internal wiki, prevents this.
Treating the prefix as a security boundary. A prefix tells you what kind of thing an ID refers to; it does not tell you whether the caller is authorized to access it. Validate the prefix for correctness and validate authorization separately, every time.
Inconsistent casing or delimiters. Pick lowercase, pick underscore, and enforce it with a shared parsing utility rather than ad hoc string slicing scattered across the codebase. Divergent implementations of "the same" format are worse than no format at all.
Forgetting to strip the prefix before comparing against a UUID column. If you're on the Option B storage pattern, every code path that accepts a prefixed ID as input needs to consistently extract and validate the raw identifier before it touches a query. Missing this in even one endpoint reintroduces the exact confusion the prefix was meant to eliminate.
Wrapping Up
Prefixed UUIDs are a low-cost, high-leverage convention: a couple of characters and an underscore in exchange for immediate type context in every log line, ticket, and debugging session, plus a cheap guardrail against cross-entity ID mixups. The identifier itself still does the real work of guaranteeing uniqueness, so the choice of UUID version underneath the prefix, v4 for pure randomness or v7 for index-friendly sortability, matters more to your database performance than the prefix ever will. Decide early whether the prefix lives in your storage layer or only at your application boundary, document your prefix vocabulary somewhere your whole team can see it, and treat it as a readability and defense-in-depth tool rather than an authorization mechanism, and you'll get the benefit without the downsides.
Tools and Versions
Need some UUIDs for testing? Try our tools for generating
UUIDv4
UUIDv7
ULID
NanoID
and
decoding
validating.
For a detailed comparison of UUID versions, visit our Complete Guide to UUID Versions.