A Deep Dive into Go's Random Generators

While geeking out over a function that generates a random number during my university years, I was surprised to learn that it is actually impossible for computers to produce a truly random number, as it goes against their nature. Well, I knew computers were deterministic and I shouldn't have been surprised by this information, but I was still amazed.

In my professional career, I didn't have to work with random generators a lot but when I did, I took a deeper look at the built-in random functions in the programming languages I have worked.

Before moving on to the Go packages, let's divide random number generators into 3 categories:

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. So 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
}
  1. r.Int31(): This call retrieves a raw 31-bit random integer from the global generator's internal algorithm (which was seeded by the OS).
  2. 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.
  3. 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()).
  4. 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:

n := big.NewInt(100)
v, err := rand.Int(rand.Reader, n)

This leads us to below function but before examining that, let's talk a bit about rand.Reader. It is an object that provides random bytes through the io.Reader interface. In fact, rand.Reader is an implementation backed by the OS's CSPRNG, as we discussed earlier. It retrieves values generated by the OS's CSPRNG via system calls.

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
        }
    }
}
  1. It calculates max-1 and determines how many bits and bytes are needed to represent it.
  2. It reads that many random bytes from the provided rand source (crypto/rand.Reader).
  3. It masks unused bits in the most significant byte to reduce bias.
  4. It converts the byte slice to a big.Int.
  5. If the resulting number is greater than or equal to max, it retries (rejection sampling) to maintain uniform distribution.

I hope this article has clearly covered the categories of random generators and the mechanics of it in Go's math and crypto 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.

© 2026 Nazım Burak