The Unreasonable Power of Identifiers
Why the most important engineering decision in your system might be the one you spent the least time thinking about

Written by Richard Clark, Systems Engineer
Introduction: “It’s just an ID”
Open any codebase and you’ll find them everywhere. Primary keys, foreign keys, user IDs, session IDs, correlation IDs. Identifiers are the connective tissue of every system we build. And yet, when starting a new project, the question of how to generate an ID is often answered in seconds: auto-increment, or uuid.v4(), and move on.
This is a mistake. An identifier is not “just a number.” It is a contract between every system that touches it. The scheme you choose shapes how your indexes perform, whether your system can seamlessly shard across regions, what information an engineer can read from an ID at a glance, and what you inadvertently reveal to an attacker. Few concepts in software touch so many concerns at once: performance, security, human ergonomics, and the messy problem of modelling the real world in a fixed-width integer. This post unpacks the engineering behind identifier design, and uses CUULID, a custom scheme we built at Cogna, as a case study.
Part 1: What’s Inside a Number
Everything in mainstream computing rests upon base 2, the simplest positional number system. Binary is the thread connecting Boole’s algebra, Church and Turing’s models of computation, and Shannon’s realisation of logic in circuitry. From that single bit we build compilers, kernels, virtual machines, the OSI model, ORMs, dependency injection, Kubernetes, React, AbstractSingletonProxyFactoryBean, and node_modules. Identifiers are one of the few places this whole edifice collapses into a single value: a handful of bytes at one end, a customer at the other; equally answerable to the CPU and to accounts receivable.
For decades, auto-incrementing integers were the obvious choice for primary keys. They’re compact, fast, and simple. But as systems grew APIs and public interfaces, a problem emerged: if a user sees invoice/1042 in a URL, there’s nothing stopping them trying invoice/1043. Auto-incrementing integers make performant primary keys, but dangerous public identifiers. They also bind your data to the sequence that generated it: restoring into a different environment, branching a database, or merging two datasets risks collisions.
The UUID specification offered an alternative. Its most widely adopted variant, UUID v4, replaced predictable sequences with 122 bits of randomness, and the ability to generate them without coordination made UUIDs a natural fit for distributed systems. Over time, many teams adopted them as primary keys too, not just external identifiers. That choice carries costs that are worth understanding.
Bits, bytes, and word sizes
At the database layer, identifiers are ideally fixed-width binary integers. Modern CPUs operate natively on 64-bit words: comparing two 64-bit integers is a single instruction, as cheap as comparing a pointer.
A UUID is 128 bits (16 bytes). It doesn’t fit in a general-purpose register. Comparing two UUIDs may require multiple loads and comparisons; hashing and copying cost proportionally more. The overhead is small in absolute terms, but it multiplies across millions of index lookups and join operations. SIMD can sometimes close the gap; SSE and NEON registers are 128 bits wide, and a sufficiently smart memcmp should vectorise the comparison, but availability varies, and the general-purpose cost remains the baseline to reason from.
That said, comparison cost is not where identifiers earn or spend their performance budget. The dominant factors are index locality and write amplification. These depend less on the width of the value and more on its ordering properties. We’ll return to this in Part 3.
How numbers live in memory
Byte order matters more than you might expect. x86 and ARM are nominally little-endian: least significant byte first. Network byte order is big-endian: most significant byte first. This matters for time-ordered IDs because databases like PostgreSQL compare UUIDs byte-by-byte starting from byte 0. If the most significant part of the timestamp isn’t in the leading byte, the comparison won’t reflect chronological order. Established schemes like UUID v7 handle this correctly. Custom schemes that pack data into a UUID-compatible 16-byte layout need to get this right, or the database’s sort order won’t match the logical order.
The diagram below illustrates this with a simple example: the same timestamp stored in big-endian and little-endian byte order. When memcmp compares from byte 0, the big-endian layout gives the correct chronological ordering. The little-endian layout puts the least significant byte first, which reverses the comparison result.

Cache lines are typically 64 bytes. A 64-bit integer is 8 bytes: eight IDs per cache line. A 128-bit UUID is 16 bytes: four per line. When a database scans an index, this factor-of-two difference in density means more cache misses and more stalls.

How numbers become text
A UUID is 16 bytes in binary. Since identifiers need to be readable in logs, URLs, and API responses, we encode them as text. In the canonical hex-with-hyphens format, that’s 36 bytes — more than twice the size. This distinction matters at the storage layer too: a uuid column in PostgreSQL stores 16 bytes, but a VARCHAR column storing the same ID as text stores 36 bytes plus overhead.
Alternative encodings trade alphabet size for density. Hex gives 32 characters. Base64 gives 22 — compact, but case-sensitive and not URL-safe without modification. Crockford’s Base32 is a pragmatic middle ground: 26 characters, URL-safe, case-insensitive, and deliberately excludes I, L, O, and U to avoid visual ambiguity with 1, 0, and V (though disappointingly sidestepping accidental profanity in the process). It’s what ULID uses, and what we chose for CUULID.
How numbers live on disk
PostgreSQL stores B-tree index data in 8 KB pages. A 32-bit (4-byte) integer key gives you roughly 2,000 entries per page; a 128-bit UUID gives you roughly 500. Fewer entries per page means more pages to read, more I/O amplification, and a larger write-ahead log — every byte of your ID is a byte replicated to every standby. We’ll return to the behavioural consequences of this shortly.
Part 2: The Landscape
The number of identifier schemes in the wild is itself a signal: no single approach is universal. Each makes different trade-offs across a set of competing requirements.
What might you need from an identifier?
Requirement | Description-------------------------+----------------------------------------------------------Global uniqueness | No coordination needed between generatorsSortability | Lexicographic ordering reflects creation orderMonotonicity | Strictly increasing, even within the same time instantCompact representation | Minimise storage and transmission overheadHuman readability | Easy to copy, paste, read aloud, spot in logsOpacity | Cannot be predicted, enumerated, or reverse-engineeredEmbedded metadata | Encode type, region, shard, timestamp, or other dataDatabase friendliness | Good index locality, minimal page splitsDecentralised generation | No central authority or coordination pointNo scheme satisfies all of these simultaneously.
Three families
Identifier schemes fall into three broad families, distinguished by what occupies the most significant bits:
Sequential — auto-incrementing integers. Compact, perfectly ordered, excellent index locality. The costs: single point of generation, trivially enumerable values, and leaked cardinality. Frequently the right choice for internal-only identifiers, and when they are, hard to beat.
Random — UUID v4 and its relatives. No coordination, no information leakage, negligible collision risk. The same randomness that makes them secure makes them hostile to B-tree indexes, as we’ll see in the next section.
Time-ordered — a timestamp in the high bits, randomness or a sequence counter below. ULID, UUID v7, Snowflake, KSUID. An attempt to combine the index-friendliness of sequential IDs with the decentralisation of random ones, at the cost of embedding a timestamp in every ID.
Comparing the common schemes
Scheme | Bits | Sorted | Coordination-free | Entropy | Encoding | Notes---------------+----------+--------+-------------------+------------+-------------------------+-------------------------------------------------Auto-increment | 16/32/64 | Yes | No | None | Decimal | Perfect locality, trivially enumerableUUID v4 | 128 | No | Yes | 122 bits | Hex (36 chars) | The default choice; hostile to B-treesUUID v7 | 128 | Yes | Yes | 62-74 bits | Hex (36 chars) | The modern standard; 48-bit ms timestampULID | 128 | Yes | Yes | 80 bits | Crockford Base32 (26) | Predates UUID v7, which converged on a similar designSnowflake | 64 | Yes | No (machine ID) | 12-bit seq | Decimal | 4,096 IDs/ms/machine; fits in a registerStripe-style | Variable | No | Yes | Variable | Prefixed string | cus_, pi_ - type visible in the ID itselfThe table reveals a gap: no scheme combines time-ordering, embedded type information, and PostgreSQL’s native uuid storage. That’s the space CUULID was designed to fill.
Part 3: The B-tree Problem (or, Why UUID v4 Hurts)
How B-tree indexes work
PostgreSQL’s default index is a B-tree: a balanced, sorted tree of 8 KB pages. Internal pages hold keys and pointers to child pages; leaf pages hold keys and pointers to heap tuples. Because the tree is sorted, any lookup is O(log n) — descend from root to leaf, comparing keys at each level.
The insert path matters most for identifiers. To insert a new key, PostgreSQL walks the tree to the correct leaf page. If the page has room, the key is added in sorted order. If it doesn’t, the page splits: half the entries move to a new page, and the parent is updated to point to both. Splits are expensive because they generate extra I/O, produce write-ahead log (WAL) traffic, and can cascade upward if the parent page is also full.
Why random IDs hurt
With auto-incrementing keys, inserts always land at the rightmost leaf page. That page stays in the buffer cache. Splits are rare and orderly - the tree grows to the right like a vine.
With UUID v4, inserts are uniformly distributed across the entire key space. Every insert potentially touches a different leaf page, most of which are not in cache. The database pulls pages from disk, modifies them, and writes them back — only for the next insert to touch a completely different page. The buffer cache, designed to keep hot pages in memory, has nothing hot to keep.

The cascading effects are measurable. Random inserts cause more frequent page splits, which leave pages roughly 69% populated on average (compared to ~90% for sequential inserts). That 20% of wasted space compounds across millions of rows into significant index bloat. More splits also mean more WAL traffic (every structural change is written to the log and replicated to every standby), which slows replication and increases storage churn.


Time-ordered IDs as the remedy
UUID v7, ULID, and other time-prefixed schemes restore locality. Inserts cluster at the “right edge” of the index, much like auto-increment. The B-tree stays compact, the hot page stays in cache, and WAL traffic drops.
Snowflake IDs achieve the same locality in 64 bits: a timestamp in the high bits gives sequential index behaviour, and the entire ID fits in a single register. These advantages come with associated costs: ID generation requires a network call to a dedicated service, and that service needs a coordination layer (typically Zookeeper).
There’s a second, quieter benefit: sortable IDs give you zero-overhead keyset pagination. Instead of OFFSET 10000 LIMIT 20 (which forces the database to skip over ten thousand rows it won’t return), you write WHERE id > :last_seen_id ORDER BY id LIMIT 20. The index seeks directly to the right place. No skipped rows, no degradation as the user pages deeper, and the query plan is the same whether you’re on page one or page five thousand.
The trade-off is explicit: you now leak approximate creation time in the ID itself. For internal primary keys this is usually acceptable. For externally-exposed identifiers (API responses, URLs) it deserves more thought. We’ll address that tension in the next section.
Beyond B-trees
Not every storage engine suffers equally. LSM trees (RocksDB, Cassandra) absorb random writes more gracefully, at the cost of read amplification. PostgreSQL’s BRIN indexes are ideal for time-ordered data but useless for random UUIDs. Hash indexes work when you only need equality lookups. B-trees are not the only consideration, but they are the default index type in every major relational database.
Part 4: Encoding Information (and What You Leak)
An identifier doesn’t have to be opaque. Some ID schemes treat the identifier as a tiny, fixed-size data structure. But structure costs entropy, and what you encode is what you expose.
What can you encode?
Anything, essentially, as long as it only requires a few bits.
Timestamps: Creation time without a database query. Enables time-range queries on the ID itself.
Type/entity prefixes: Instantly distinguish a user ID from an order ID from a session token. Invaluable for debugging, logging, and API design.
Shard keys: Embed the target shard or partition directly in the ID. Route requests without a lookup table.
Region/datacenter: Enable geographic routing. A European ID routes to the European cluster.
Machine IDs: In a Snowflake-style scheme, encode the generator’s machine ID to guarantee monotonicity without coordination.
Sequence counters: Guarantee ordering within a time window.
The bit budget
You have a fixed number of bits. Every feature you encode reduces entropy (and therefore collision resistance). To give a more concrete example, here’s how CUULID allocates its 128 bits:
128 bits total - 10 bits for type prefix -> 1,024 possible types - 48 bits for timestamp -> millisecond precision until year 8921 - 70 bits remaining entropy -> ~1.18 x 10^21 possible values per millisecondIs 70 bits of entropy enough? At 1 million IDs per second, the birthday-bound probability of collision within a single millisecond is approximately one in two quadrillion. For most systems, this is more than adequate.
Type prefixes: string or structure?
Stripe popularised the pattern of prefixed IDs (cus_, pi_, sub_). Their prefixes are string-level, prepended to the outside of the random payload. This is an excellent pattern for API ergonomics: a developer reading a log line or debugging a webhook can instantly tell what kind of entity they’re looking at, with no tooling required. If your identifiers are primarily consumed by humans through APIs, it’s hard to beat.
Stripe hasn’t publicly documented the internals of their ID system. Whether they store the full prefixed string or strip the prefix and decode the payload into a compact binary form is unknown. The trade-offs outlined here apply to the pattern as adopted by others.
A string-prefixed ID can’t be stored as a native fixed-width type like PostgreSQL’s uuid. You’re either storing it as TEXT or VARCHAR (paying for variable-length storage, slower comparisons, and larger indexes) or maintaining a translation layer that strips the prefix on the way in and reattaches it on the way out. Neither is fatal, but both are costs, and they compound as your table grows.
If you need the human-readable type information and fixed-width binary storage, the alternative is to encode the type within the identifier’s bits. The type becomes part of the sortable, indexable value. Fixed total size. Survives storage as a raw 128-bit value. And because same-type IDs share their most significant bits, you get the index locality benefits discussed in Part 3 for free.
The security trade-off
Every bit of structure is a bit of information you expose. Time-ordered IDs leak creation time. Type prefixes reveal your data model. Shard bits reveal your infrastructure topology. For internal identifiers (database primary keys, service-to-service correlation IDs) this is usually acceptable and the performance benefits are worth it.
For externally-visible identifiers, the calculus changes. Sequential integers are the worst case: an attacker can enumerate all entities, determine your total count, and infer growth rate. It’s one of the most common API security vulnerabilities (OWASP’s Broken Object Level Authorisation, also known as IDOR). But even time-ordered IDs with high entropy can leak business activity patterns to a sufficiently motivated observer.
The question is not whether your IDs leak information, but whether that information matters. For a public-facing e-commerce system, it’s likely that neither creation time nor activity patterns are sensitive. For healthcare or financial records, both might be. Start from your threat model: who sees these IDs, what can they infer, and what could they do with that inference? If the answers are uncomfortable, a mapping layer between internal time-ordered IDs and external opaque tokens is straightforward to add. A hash index on the external ID column is potentially a natural fit here: you only need equality lookups, and the random distribution that makes UUID v4 hostile to B-trees is irrelevant to a hash function. Otherwise, the complexity isn’t worth it.
It’s worth noting that crypto/rand (or your language’s equivalent CSPRNG) is non-negotiable for the entropy portion. math/rand is predictable and must never be used for identifiers that leave your system.
Part 5: Designing CUULID
Sometimes the existing schemes come close but not close enough. At that point, it’s worth exploring what it would mean to build something of your own.
The surface area for mistakes is large: encoding bugs, endianness issues, insufficient entropy, broken monotonicity. But as we’ve seen, an ID scheme is difficult to change after the fact, and a compromise you accept at the start tends to stick.
The requirements that led us here
Most of these requirements are familiar from the preceding sections. What pushed us beyond off-the-shelf schemes was the combination of: type encoding and PostgreSQL’s native uuid storage and monotonic generation, without giving up sortability or human readability. The immediate trigger was a system where configuration routinely contained a mix of conceptually related IDs, all similar-looking 128-bit values that needed to be distinguishable at a glance.
Anatomy of a CUULID
We call our scheme CUULID: Cogna Universally Unique Lexicographically Sortable Identifier. It is a 128-bit value encoded as 26 Crockford’s Base32 characters:

Type prefix (10 bits): Two Crockford’s Base32 characters encode one of up to 1,024 entity types.
Timestamp (48 bits): Milliseconds since Unix epoch. Sufficient until the year 8921.
Entropy (70 bits): Cryptographically random data from crypto/rand.
Design decisions and their rationale
The encoding choice
Crockford’s Base32 provides a compact ID that is case-insensitive, URL-safe, and visually unambiguous. The trade-off is a slightly more involved encoding implementation, which we’ll see below.
Bit-level type encoding
A string prefix (cs_) would break UUID compatibility and increase storage
Encoding in the high bits means same-type IDs cluster in B-tree indexes
The type is preserved even when stored as a raw 16-byte UUID in PostgreSQL
Entropy budget
We “spent” 10 bits on the type prefix
As we saw in the bit budget calculation, the collision risk at 70 bits remains negligible at any realistic generation rate
Monotonic generation
A factory pattern increments the entropy portion for IDs generated within the same millisecond
Thread-safe via mutex locking
Without this, IDs generated in the same millisecond sort randomly relative to each other
Building it
The remainder of this section walks through a Go implementation. The full code is more involved, but these excerpts cover the essential moving parts: the core type, packing bits into bytes, the prefix registry, monotonic generation, and the encoding cost of Crockford’s Base32.
The core type
A CUULID is 16 bytes. Nothing more.
type CUULID [16]byteThis is the same width as a UUID, which means it fits directly into PostgreSQL’s uuid column type with no conversion overhead at the storage layer.
Packing the bits
The New function takes a two-character prefix, a millisecond timestamp, and an entropy source, and packs them into 16 bytes:
func New(prefixChars string, ms uint64, entropy io.Reader) (CUULID, error) { var id CUULID
// Encode the 2-character prefix into 10 bits (5 bits per character) val1 := decodingTable[prefixChars[0]] val2 := decodingTable[prefixChars[1]] prefix := uint16(val1)<<5 | uint16(val2) id[0] = byte(prefix >> 2) id[1] = byte((prefix & 0x03) << 6)
// Set the timestamp (48 bits) starting at bit 10 id[1] |= byte((ms >> 42) & 0x3F) id[2] = byte((ms >> 34) & 0xFF) id[3] = byte((ms >> 26) & 0xFF) id[4] = byte((ms >> 18) & 0xFF) id[5] = byte((ms >> 10) & 0xFF) id[6] = byte((ms >> 2) & 0xFF) id[7] = byte((ms << 6) & 0xC0)
// Fill the remaining 70 bits with cryptographic randomness var e [9]byte io.ReadFull(entropy, e[:]) id[7] |= e[0] & 0x3F // 6 bits from first entropy byte copy(id[8:], e[1:]) // remaining 8 bytes
return id, nil}Notice how the components don’t align to byte boundaries. The prefix ends at bit 10, which is two bits into byte 1. The timestamp ends at bit 58, which is two bits into byte 7.
Extracting metadata
The inverse of packing: given a CUULID, extract the prefix and timestamp by reversing the bit shifts.
func (id CUULID) Prefix() string { prefix := uint16(id[0])<<2 | uint16(id[1]>>6) return string([]byte{ encodingTable[prefix>>5], encodingTable[prefix&0x1F], })}
func (id CUULID) Timestamp() uint64 { return uint64(id[1]&0x3F)<<42 | uint64(id[2])<<34 | uint64(id[3])<<26 | uint64(id[4])<<18 | uint64(id[5])<<10 | uint64(id[6])<<2 | uint64(id[7]>>6)}Within business logic, logs, debugging sessions, and monitoring dashboards, you can extract the entity type and creation time from the ID alone, with no database lookup required.
The prefix registry
Prefixes are registered at compile time with pre-computed bit values:
const ( Customer RegisteredPrefix = "CS" Order RegisteredPrefix = "0R" Product RegisteredPrefix = "PR" Invoice RegisteredPrefix = "NV" Payment RegisteredPrefix = "PM" Shipment RegisteredPrefix = "SH")Each prefix is exactly two Crockford’s Base32 characters (5 bits each, 10 bits total). Go’s type system can’t express that constraint directly, so RegisteredPrefix is a string validated at build time.
Prefixes are a closed set by design. Adding a new prefix is a code change, not a configuration change, which means it goes through code review and you can’t accidentally exhaust your 1,024-prefix budget without someone noticing. It also gives you compile-time type safety: you can assert the type of an ID and branch business logic accordingly.
Monotonic generation
By default, two CUULIDs generated in the same millisecond will sort randomly relative to each other. If your use case requires strict ordering within a millisecond, a MonotonicCUULIDFactory handles this:
type MonotonicCUULIDFactory struct { sync.Mutex lastTime uint64 lastId CUULID entropy io.Reader}
func (mf *MonotonicCUULIDFactory) New(prefixChars string) (CUULID, error) { mf.Lock() defer mf.Unlock()
ms := Timestamp(time.Now())
if ms <= mf.lastTime && mf.lastId.Prefix() == prefixChars { // Same millisecond, same prefix: increment the entropy portion temp := mf.lastId for i := len(temp) - 1; i >= 8; i-- { temp[i]++ if temp[i] != 0 { break } } mf.lastId = temp return temp, nil }
// Time has advanced: generate a fresh CUULID id, _ := New(prefixChars, ms, mf.entropy) mf.lastTime = ms mf.lastId = id return id, nil}The factory remembers the last ID it generated. If the clock hasn’t advanced, it increments the entropy portion by one, carrying from the least significant byte. This guarantees strict ordering without sacrificing randomness: the first ID in each millisecond is fully random, and subsequent IDs in that millisecond are sequential from that random starting point. This ordering guarantee is scoped to a single factory instance. Across processes, same-millisecond IDs will have independent entropy and won’t necessarily sort in generation order, though collision probability remains negligible.
The cost of Crockford’s Base32
Hex encoding is trivial: each byte splits into two 4-bit nibbles, and each nibble maps to one character. The 4-bit chunk size divides evenly into 8-bit bytes, so every character boundary is a nibble boundary.
Crockford’s Base32 uses 5-bit characters. Five does not divide eight. This means character boundaries routinely fall mid-byte, and encoding requires bit-shifting across byte boundaries:
// Timestamp character 3: bits split across id[2] and id[3]b[4] = encodingTable[((id[2] & 0xF) << 1) | ((id[3] >> 7) & 0x1)]
// Timestamp character 5: contained within a single byteb[7] = encodingTable[id[4] & 0x1F]Compare this to hex, where encoding a byte is always the same two operations:
b[0] = hexTable[id[0] >> 4]b[1] = hexTable[id[0] & 0x0F]The 5-bit encoding is measurably more complex to implement and harder to get right: every character position has a different shifting pattern. The Parse function has a 26-case switch for the same reason. Crockford’s Base32 pays for its compactness in implementation complexity, not runtime performance. The bit operations are fast, but every character position has a different shifting pattern, and the potential for off-by-one errors in the shifting logic is real. We accepted this cost for the density (26 characters vs 32 for hex, 36 for UUID format) and the human-friendliness of the alphabet.
Talking to PostgreSQL
CUULID implements Go’s driver.Valuer and sql.Scanner interfaces, so it works directly with database/sql and libraries like sqlx or pgx.
The Value method serialises the 16 bytes as a standard UUID hex string:
func (id CUULID) Value() (driver.Value, error) { return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x", binary.BigEndian.Uint32(id[0:4]), binary.BigEndian.Uint16(id[4:6]), binary.BigEndian.Uint16(id[6:8]), binary.BigEndian.Uint16(id[8:10]), binary.BigEndian.Uint64(append([]byte{0, 0}, id[10:16]...)), ), nil}The Scan method handles the reverse, parsing the UUID string that PostgreSQL returns back into 16 bytes:
func (id *CUULID) Scan(src any) error { switch v := src.(type) { case string: return id.parseUUIDHex(v) case []byte: if len(v) == 16 { copy(id[:], v) return nil } return id.parseUUIDHex(string(v)) default: return fmt.Errorf("cuulid: unsupported scan type %T", src) }}PostgreSQL stores this as 16 bytes internally, regardless of the string format it arrived in. CUULID’s byte order is big-endian by construction, which means PostgreSQL’s uuid comparison operators produce the same ordering as our Crockford’s Base32 string comparison. ORDER BY id gives you chronological order within each entity type, no special handling needed.
CUULID has an extensive test suite covering generation, parsing, string roundtrips, timestamp precision, bit-boundary edge cases, and driver.Valuer/sql.Scanner compliance. PostgreSQL integration tests using testcontainers verify storage, retrieval, and sort ordering. Benchmarks on an Apple M4 Pro show around 90ns per ID with crypto/rand entropy, 15ns for string encoding, 22ns for parsing, and sub-nanosecond timestamp extraction.
Conclusion: The Weight of a Small Decision
Identifiers are deceptive. They look trivial. But as we’ve seen, the choice of scheme ripples outward into your index performance, your replication lag, your security posture, and the migration you’ll eventually wish you didn’t have to do.
Before reaching for the default, consider what your system actually needs:
Generation model: If IDs are created by multiple services or in multiple regions, you need coordination-free generation. This rules out auto-increment and Snowflake-style schemes that depend on a central allocator or machine registry.
Index behaviour: If your database uses B-tree indexes (and most relational databases do), time-ordered IDs will dramatically outperform random ones. LSM-tree stores are more forgiving.
External exposure: If IDs appear in API responses or URLs, assess your threat model. Sufficient entropy is non-negotiable. If the metadata encoded in the ID is sensitive, consider a mapping layer to opaque external tokens.
Embedded metadata: If you need type, region, or shard information in the ID itself, plan your bit budget. Every bit of structure is a bit of entropy you give up and a bit of information you expose.
For most new projects in 2026, UUID v7 is the right starting point. It’s time-ordered, globally unique, increasingly well-supported, and kinder to your indexes than UUID v4. When you need more (type semantics, a compact encoding, guaranteed monotonicity) you’re in the territory of custom schemes like CUULID, and the preceding sections should give you a framework for designing your own.
ID schemes are cheapest to get right at the start of a project and expensive to change after. Either way, it helps to understand what’s really inside those “just numbers.”
Richard Clark is an engineer at Cogna, where he works at the intersection of backend systems, infrastructure, and security. Outside of work he likes to dig holes, sometimes in gardens, sometimes in codebases. His remaining free bits are allocated to his children.


