Table of contents
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:
| Operation | ns/op | B/op | allocs/op |
|---|---|---|---|
UID64_Format | 2.0 | 0 | 0 |
UID128_Format | 4.2 | 0 | 0 |
UUID_Format | 4.7 | 0 | 0 |
UUID_Byte_NoEscape | 7.1 | 0 | 0 |
UID64_Hex | 15.0 | 16 | 1 |
UID128_Hex | 21.5 | 32 | 1 |
UUID_String | 26.3 | 48 | 1 |
RandUint32 | 35.2 | 4 | 1 |
RandUint64 | 40.3 | 8 | 1 |
RandomBytes | 62.0 | 16 | 1 |
UID128 | 64.5 | 8 | 1 |
UUIDv7 | 69.2 | 8 | 1 |
RandString | 200.2 | 32 | 2 |
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.
Links
- Reference documentation
- Source code
- In depth: Random IDs in Go, Trap by Trap
- Fixed-width hex encoding: uhex
- Deterministic keys instead: stringkey
- All nurago packages