Random IDs in Go, Trap by Trap: UUIDv7 and Unbiased Random Strings

Five subtle ways random-identifier code goes wrong, and how the nurago random package answers each: entropy budgeting in UUIDv7, rejection sampling, reader contracts, and allocation-free formatting.


Generating random identifiers looks trivial and hides quiet traps. Reach for math/rand out of habit and your “secure” token is predictable. Map random bytes onto an alphabet with a naive % len and you skew the distribution. Accept whatever a custom entropy reader hands you and you can end up with truncated randomness, or a call that never returns.

The random package in nurago centralises these patterns behind one small API. New(nil) draws from crypto/rand.Reader, the right default for anything security-sensitive, and a custom io.Reader can be supplied for testing or specialised entropy sources:

import "github.com/tecnickcom/nurago/pkg/random"

r := random.New(nil) // nil => crypto/rand.Reader

id    := r.RandHex64()       // 16-char hex ID
short := r.RandString64()    // compact base-36 ID
tok, err := r.RandString(24) // configurable-alphabet string

Rather than touring that API, this post walks through five specific ways this kind of code goes wrong, and shows how the current implementation answers each one. The interesting parts all live at the edges: unusual alphabets, misbehaving readers, hot paths.


Trap 1: drawing more entropy than the format needs

UUID version 7, defined in RFC 9562, is the modern default for database keys: a universally unique identifier (UUID) whose high bits are a millisecond Unix timestamp, so values are time-ordered and index-friendly, with randomness filling the rest. The layout is 48 bits of timestamp, a 4-bit version, a 12-bit rand_a field, a 2-bit variant, and a 62-bit rand_b field.

The naive construction generates 16 random bytes, version-4 style, then overwrites the first six with the timestamp and stamps the version and variant on top. It works, but half the entropy you paid for is discarded. r.UUIDv7() budgets instead: the timestamp and version cost nothing, and rand_a is not random here at all. It carries the nanosecond remainder of the current millisecond, scaled into [0, 4095]:

ns := int((int64(now.Nanosecond()%1e6) * 4294) >> 20)

(4294 is 4095 x 2^20 / 10^6, so the shift by 20 performs the division.) This is RFC 9562 section 6.2 Method 3, “Replace Leftmost Random Bits with Increased Clock Precision”, and it is what makes values minted within the same millisecond sort correctly. The test suite pins it: 20,000 consecutive values must be non-decreasing on their first eight octets, and rand_a must visit well over a hundred of its 4,096 buckets.

That leaves rand_b as the only field that needs entropy, so the implementation reads exactly 8 random bytes and masks the variant over the top two bits of the first, keeping 62 of the 64 bits it read:

var rb [8]byte

err := readFull(r.reader, rb[:])
if err != nil {
	r.notifyFallback()

	binary.LittleEndian.PutUint64(rb[:], mrand.Uint64()) //nolint:gosec
}

ub[8] = 0x80 | (0x3F & rb[0])
copy(ub[9:16], rb[1:8])

Two costs are worth knowing. That 8-byte buffer is heap-allocated: the read goes through a configurable io.Reader, so escape analysis cannot keep it on the stack, and each call makes that one small allocation. And the method holds no shared mutable state and takes no locks of its own, so a single generator is safe to share across goroutines; the trade is that ordering within the same sub-millisecond instant is statistical, not strictly monotonic.


Trap 2: modulo bias in character selection

You have a random byte in [0, 256) and an alphabet. The obvious mapping is alphabet[b % len], and it is wrong whenever 256 is not a multiple of the length. The default map here has 90 entries (digits, letters, symbols), and 256 = 2 x 90 + 76, so a naive b % 90 gives the first 76 characters three byte values each and the last 14 only two: the front of the alphabet turns up 1.5 times as often. Password strength estimates assume uniformity; this quietly voids them.

RandString uses rejection sampling. It computes the largest multiple of the alphabet length that fits in a byte and discards anything at or above it before mapping:

// limit is the largest multiple of cmlen not exceeding byteRange: random bytes
// at or above it are rejected to avoid modulo bias.
limit := byteRange - (byteRange % cmlen)

(byteRange is the constant 256, so the default map rejects bytes 180 to 255.) Rejected bytes mean refills, and a careless refill loop allocates a fresh buffer each pass. Instead, entropy is read into the not-yet-finalised tail of the output buffer and accepted bytes are mapped forward in place; the write cursor does not overtake the read cursor, so one buffer serves as both scratch and result no matter how much is rejected. The whole operation is one buffer allocation plus the final string conversion.

Uniformity is pinned by a chi-square test built around the worst case, a 129-entry map where nearly half of all bytes are rejected: removing the rejection gives the first 127 entries twice the weight of the last two and sends the statistic to roughly 1500 against a pass threshold of 300.

You can supply your own alphabet, with one documented restriction: the map holds bytes, not runes, so entries must be single-byte (ASCII) values. Multi-byte UTF-8 runes are not rejected, but their bytes are drawn independently and the result is almost always invalid UTF-8.

alphaNum := []byte("0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ")
r := random.New(nil, random.WithByteToCharMap(alphaNum))
s, _ := r.RandString(16)

Trap 3: entropy that goes missing silently

There are two ways a caller of this kind of code can lose entropy without any signal. Both are instructive, and the package closes each.

The first is aliasing. If WithByteToCharMap retained the caller’s slice, mutating that slice later would silently reconfigure the generator; running clear() on it would turn RandString into a producer of NUL bytes with zero entropy, and concurrent mutation would be a data race. So the option copies the map, and the generator is immutable after construction.

The second is a design tension the package resolves explicitly. Helpers that return bare values (RandUint32, RandUint64, UUIDv7, and RandHex64, RandString64, UID64, UID128 built on them) have no error channel, so when the configured reader fails they substitute math/rand/v2, Go’s OS-seeded ChaCha8 global source. The output is not predictable, but it is no longer the source you configured, which matters when that source is a hardware security module (HSM) or an audited entropy service. With the default crypto/rand.Reader the substitution is not expected to occur, because its Read is documented not to return an error. With a custom reader it is silent by default, but it can be made observable:

r := random.New(hsmReader, random.WithFallbackHook(func() {
	log.Println("entropy source swapped to math/rand/v2")
}))

The error-returning helpers take the opposite stance: RandomBytes and RandString do not fall back and do not truncate. Short reads from a custom reader are retried until the buffer is full, a reader that ends early yields io.ErrUnexpectedEOF, and any other failure is returned to the caller. This split is also why the documentation tells you not to treat UUIDv7 as cryptographically strong: an API that cannot fail is the wrong shape for secrets. Mint identifiers with it; mint tokens and keys with RandString or RandomBytes, where a reader failure is loud.


Trap 4: a reader that never gets anywhere

The io.Reader contract permits returning (0, nil): nothing happened, try again. io.ReadFull retries that forever, which turns a degenerate but legal custom reader into an unkillable loop at full CPU inside an ordinary-looking call. So the package replaces io.ReadFull with an internal readFull that preserves its semantics but bounds consecutive empty reads (at 100) and returns ErrReaderNoProgress instead of hanging.

RandString has a second potential livelock of its own: a reader whose every byte lands in the rejection zone (a constant 0xFF source against the default map, say) makes each refill pass accept nothing, forever. Consecutive stalled passes are bounded at 128. The bound is chosen so a working reader will not trip it in practice: even the worst-case map accepts each byte with probability of about 0.504, so 128 stalls in a row from real entropy has probability of roughly 0.496^128, which is vanishingly small. The non-failing helpers convert the same condition into the fallback from trap 3 rather than an error, so nothing hangs either way. Neither path comes into play with the default crypto/rand.Reader; this is purely armour for the custom-reader contract, and the tests exercise both stalling and always-rejected readers directly.


Trap 5: paying an allocation to print

Identifiers get formatted far more often than they get minted, so the textual path is the real hot path. Format writes the canonical 36-character form into a caller-owned array using the table-driven uhex encoders: each byte is one lookup in a 256-entry table and a single 16-bit store, fully unrolled and branch-free, with no allocation.

u := r.UUIDv7()

var buf [36]byte
u.Format(&buf)  // no allocation
s := u.String() // one allocation for the returned string

The in-between helper Byte() fills a local array and returns a slice over it, so whether it allocates depends on escape analysis at the call site: free if the slice stays local, one heap allocation if it escapes. The doc comments spell out this three-tier contract (Format, then Byte, then String) rather than the usual vague “optimised for performance”, and the identifier types below follow the same pattern with [16]byte and [32]byte buffers.


The other identifiers

The same generator offers RandomBytes(n) for raw bytes under the full-buffer contract from trap 3, the fixed-width RandHex64 and variable-length base-36 RandString64, and two non-UUID identifiers. UID64 packs a decade-relative second counter into its top 32 bits and randomness into the bottom 32; with one 32-bit draw per value, birthday collisions among IDs minted in the same second reach 50% at around 77,000, so it is for high-volume but not collision-critical use, and it is time-ordered only within a decade. UID128 pairs the full 64-bit Unix nanosecond time with 64 random bits: prefer its fixed-width Hex() form, which preserves time order and round-trips; its base-36 String() concatenates two variable-length halves and is documented as display-only, since distinct values can render identically.

Between the two 128-bit options the question is reach. UUIDv7 spends 6 bits on version and variant markers and caps time precision at a millisecond plus the 12-bit sub-millisecond counter, but almost everything speaks it: a Postgres uuid column, other languages’ libraries, tracing tools. UID128 spends every bit on time and randomness and orders by nanosecond, but it is opaque 16 bytes to anything that did not mint it. If the identifier ever leaves your Go code, pick UUIDv7; if it stays in-house and fine ordering matters, UID128 earns its place.

Picking the right identifier for the job is the point of having them named, documented, and tested rather than rolled inline.