random

random generates bytes, numeric IDs, UUIDv7 and configurable strings from crypto/rand, with a documented fallback and allocation-free formatting.

Part of nurago, a collection of independent Go packages for backend services.

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

Random bytes, unsigned integers, UUIDv7, 64- and 128-bit UIDs, hexadecimal and base-36 IDs, and configurable random strings.

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

id := r.UUIDv7()
s, err := r.RandString(16)

The fallback, stated plainly

This is the part of the package that deserves the most attention, and the doc comment does not soften it.

Some helpers have error-free signatures: RandUint32, RandUint64, UUIDv7, and the RandHex64, RandString64, UID64 and UID128 built on them. They cannot return an error, so when the reader fails they fall back to math/rand/v2.

RandomBytes and RandString never fall back. They return the reader’s error.

With the default crypto/rand.Reader the fallback is unreachable, because its Read cannot return an error. It becomes reachable when you supply your own reader, and it is silent: the entropy source is swapped with no error and no signal.

WithFallbackHook is how you observe it. Register one if you supply a custom reader and the randomness is security-relevant.

The fallback draws from Go’s OS-seeded ChaCha8 global source, so the output is not predictable in the cryptographic sense. It is nonetheless not the source you configured, which for a token or a key is a meaningful difference.

A reader that never makes progress, returning zero bytes with a nil error, is not retried forever.

UUIDv7 is time-ordered

UUIDv7 puts a millisecond timestamp in its leading bits, so generated IDs sort chronologically.

That matters wherever the ID becomes a database primary key. UUIDv4 is uniformly random, so inserts land at random positions in a B-tree index, fragmenting pages and defeating the cache. UUIDv7 inserts append near the end, which is the access pattern the index is built for.

The trade is that the creation time is visible in the identifier. For a public-facing ID, that may be information you did not intend to publish.

httpclient generates one of these when an outbound request has no trace ID in context.

Generation against formatting

The benchmarks split these apart, and the difference is instructive:

Operationns/opB/opallocs/op
UID64_Format2.000
UID128_Format4.200
UUID_Format4.700
UUID_Byte_NoEscape7.100
UID64_Hex15.0161
UID128_Hex21.5321
UUID_String26.3481
RandUint3235.241
RandUint6440.381
RandomBytes62.0161
UID12864.581
UUIDv769.281
RandString200.2322

Measured with go test -bench on Go 1.27.1, linux/amd64, Intel Core i7-1260P. Run go test -bench=. ./pkg/random/ to reproduce them on your own hardware; the absolute values will differ, the ratios much less so.

Two things worth taking from this.

Formatting can be allocation-free. UID64_Format runs at 2 ns with zero allocations when the result does not escape, against 15 ns and one allocation for the hex string form. The Byte_NoEscape and Byte_Escaping pairs show the same split: 7.1 ns and nothing when the compiler can keep the buffer on the stack, 37.5 ns and 48 bytes when it cannot. On a hot path, write into a buffer you own.

Drawing entropy dominates. Generating a UUIDv7 costs 69 ns, of which the formatting is under 5. The randomness is the expensive part, which means caching formatted representations saves little.

Unbiased strings

RandString(n) returns n bytes drawn from a character map, using rejection sampling rather than the modulo reduction that appears in most quick implementations.

rand % len(charset) is biased whenever the charset size does not divide 256 evenly: the first few characters come up slightly more often than the rest. For a display string that is harmless; for a token, it reduces the effective keyspace. Rejection sampling discards bytes at or above the largest multiple of the map length, which costs an occasional extra draw and removes the bias.

The implementation reads entropy into the tail of the output buffer and maps accepted bytes forward in place, so the whole operation is one allocation plus the final string copy however many draws are rejected.

Two boundary behaviours: a negative length returns ErrNegativeLength rather than panicking, and zero returns an empty string.

WithByteToCharMap supplies a custom map, with one constraint the package states and does not enforce: entries must be single-byte ASCII. Multi-byte UTF-8 runes are not rejected, and because their bytes are drawn independently the result is almost always invalid UTF-8.

Dependencies

This package reaches no external module: it uses only the Go standard library.