A Deep Dive into Go’s Random Generators
2025-10-18
While geeking out over a function that generates a random number during my university years, I was surprised to learn that it is actually physically impossible for a computer to generate a truly random number with its own mathematical logic.
In my professional career, I have often used random number generators in many places without thinking about it much. But when I chose to look under the hood of how random number generation works in Go, I was excited by the logic behind it.
True Random Number Generators (TRNG): They benefit from entropy. They offer an unpredictable base by using hardware and external randomness such as thermal noises, ring oscillators and avalanche noise. It requires hardware-level operation and is therefore relatively slower.
For example in Intel processors, the on-chip hardware random number generator collects entropy from multiple physical sources within the CPU and can be accessed via the RDRAND and RDSEED instructions.
Cryptographically Secure Pseudo-Random Number Generator (CSPRNG): It generates a number with the seed it receives generally from the TRNG. It takes this seed and finds the output by passing it through secure cryptographic algorithms. It is more performant than TRNG because it is no longer dependent on external sources. For the same seed, the output is also the same. However, CSPRNGs are designed to be secure. They frequently reseed from a TRNG and their cryptographic algorithms ensure that the seed cannot be recovered from the generated output.
For example on Linux, user-space applications can access cryptographically secure random bytes via the getrandom() system call. The kernel’s CSPRNG ensures that output is unpredictable and safe for cryptographic use.
Pseudo-Random Number Generators (PRNG): The simplest form of random generators. It takes a seed value at the beginning and puts this seed into a calculation to generate an output. Like CSPRNGs, they are also deterministic. For the same seed, the output is also the same. But it doesn’t use cryptographic algorithms to keep seed safe, it just performs simple mathematical calculations. It is very fast but due to this simplicity, it shouldn’t be used for security-sensitive applications.
The choice of which of these to select is determined by a certain trade-off based on the need. Generally, common programming languages have a built-in simple random generator and a cryptographic random generator, and these solve most of our problems.
Let’s examine the built-in Go Random packages in detail:
1. math/rand
It is fundamentally a PRNG. Because with the same seed, it will always produce the exact same sequence of numbers. And it is also not a CSPRNG because it does not process the seed through cryptographic algorithms; it performs simpler calculations instead. Therefore, its predictability is high and its security level is low.
Before Go 1.20:
We had to manually seed the global generator before using the top-level functions (like rand.Intn). A common practice was to use the current time in nanoseconds as the seed:
rand.Seed(time.Now().UnixNano())
randomNumber := rand.Intn(100)
But the weakness of the seed’s entropy also limited the capability of the random generator.
After Go 1.20:
rand.Seed() function is marked as deprecated (shouldn’t be used).
The global Random Generator is now initialized automatically when it’s first called. It gets its seed from the operating system’s cryptographically secure randomness source. This ensures the initial seed is much safer and more unpredictable. However, it’s important to remember that: It is still a PRNG. The seed can be determined from the generated values, and once the seed is known, the numbers it produces can also be predicted.
To understand how it works, let’s debug the simple call to generate a random number between 0 and 99:
randomNumber := rand.Intn(100)
This leads us to:
func Intn(n int) int {
return globalRand().Intn(n)
}
The globalRand() call either loads the already initialized singleton *Rand object or if it’s the first time, it initializes it by getting the secure seed from the OS. It then calls the Intn() method on this global object.
The method checks the size of the requested range (n):
func (r *Rand) Intn(n int) int {
if n <= 0 {
panic("invalid argument to Intn")
}
if n <= 1<<31-1 {
return int(r.Int31n(int32(n)))
}
return int(r.Int63n(int64(n)))
}
Since our input 100 is small, the function proceeds with r.Int31n. This step contains the core logic for producing the final random number:
func (r *Rand) Int31n(n int32) int32 {
if n <= 0 {
panic("invalid argument to Int31n")
}
if n&(n-1) == 0 { // n is power of two, can mask
return r.Int31() & (n - 1)
}
max := int32((1 << 31) - 1 - (1<<31)%uint32(n))
v := r.Int31()
for v > max {
v = r.Int31()
}
return v % n
}
The Modulo Bias Problem: If you take a large, uniformly random number (v) and apply the modulo operator (v % n), the result might not be perfectly uniform if the range of v is not an exact multiple of n. This is called Modulo Bias.
The Solution (Loop): The code calculates a threshold (max). Any raw value (v) greater than this threshold would introduce bias. If v is too high, the code throws it away and requests a new raw random number (v = r.Int31()).
Final Result: Once a safe v is found, the modulo operation (v % n) is performed, ensuring a perfectly uniform distribution of the final random number in the range 0–99.
2. crypto/rand
It is a CSPRNG. Less performant than PRNG but provides a high level of security. This package can be thought of as a proxy or wrapper. It does not generate random numbers itself; instead, it provides a secure interface to the underlying CSPRNG system.
The most common way to generate a cryptographically secure random integer between 0 and 100 in Go is like:
nBig, err := rand.Int(rand.Reader, big.NewInt(100))
n := nBig.Int64()
This leads us to:
func Int(rand io.Reader, max *big.Int) (n *big.Int, err error) {
if max.Sign() <= 0 {
panic("crypto/rand: argument to Int is <= 0")
}
n = new(big.Int)
n.Sub(max, n.SetUint64(1))
bitLen := n.BitLen() // bits needed to represent max-1
if bitLen == 0 {
return
}
k := (bitLen + 7) / 8 // bytes needed
b := uint(bitLen % 8)
if b == 0 {
b = 8
}
bytes := make([]byte, k)
for {
_, err = io.ReadFull(rand, bytes)
if err != nil {
return nil, err
}
// Clear extra bits in the first byte
bytes[0] &= uint8(int(1<<b) - 1)
n.SetBytes(bytes)
if n.Cmp(max) < 0 {
return
}
}
}
As a result, each of these random generators has its own logic and use cases. While it's important to use PRNG for speed and testing purposes, it's also important to use CSPRNG for security-sensitive applications. Go provides both of these packages. Of course, this is not a topic that can be explained in full detail in a Medium article. But I tried to cover, as much as possible, the two most commonly used packages and the logic behind them by debugging the code.