Go ships two packages for generating random values, and the choice between them is one of the most consequential decisions in the standard library. math/rand is fast and deterministic, designed for simulations, sampling, shuffling, and anywhere repeatability or speed matters more than secrecy. crypto/rand is slower and pulls from the operating system's entropy pool, designed for anything an attacker might try to guess. Mixing them up is a security bug. This chapter walks through both packages, the seeding history, and the decision rule that keeps you out of trouble.
There are two kinds of randomness software needs, and they have almost nothing to do with each other.
The first kind is statistical randomness. You want numbers that look random in aggregate so that simulations, A/B tests, shuffles, and Monte Carlo methods give unbiased results. You also often want the numbers to be reproducible, so a failing test or a flaky simulation can be replayed. Speed matters a lot because you may generate millions of values per second. Predictability is fine because nobody is attacking the output.
The second kind is unpredictable randomness. You want numbers that an attacker cannot guess, even if they see other numbers from the same generator, even if they know how the generator works, and even if they can time your code. Speed matters less because you only generate a few values per request. Predictability is unacceptable because the attacker is the whole point.
math/rand does the first job. crypto/rand does the second. Confusing them is the bug.
If an attacker guessing the value causes a problem (account takeover, leaked data, broken authentication), use crypto/rand. Otherwise, math/rand is faster and more flexible.
math/rand is a deterministic pseudorandom number generator (PRNG). Given a starting state called the seed, it produces a sequence of numbers that look random but are completely determined by the seed. Same seed, same sequence, every time. That property is a feature, not a bug, because it's what makes tests reproducible.
Here's the simplest possible use, picking the product of the day from a catalog. Don't worry about seeding yet; we'll get to that.
The output you see will depend on your Go version. On Go 1.20 and later, the global generator is auto-seeded with a random value at program start, so you'll get a different product each run. On Go 1.19 and earlier, the program would print "Wireless Headphones" every single time because the unseeded default seed is 1, which is the historical quirk that bit a generation of Go developers.
The handful of functions you'll use most:
| Function | What it returns |
|---|---|
rand.Intn(n) | A non-negative int in [0, n) |
rand.Int63n(n) | A non-negative int64 in [0, n) |
rand.Float64() | A float64 in [0.0, 1.0) |
rand.Shuffle(n, swap) | Shuffles n elements using a swap function |
rand.Perm(n) | Returns a random permutation of [0, n) |
A discount roll, the kind of thing you'd run when a customer checks out:
rand.Intn(21) returns a number in [0, 21), and adding 5 shifts it into [5, 26), which is the inclusive range [5, 25]. The off-by-one is easy to get wrong; remember Intn(n) is exclusive at the top.
Cost: the top-level rand.Intn, rand.Float64, etc. all share a single global generator protected by a sync.Mutex. Under heavy concurrent use this mutex becomes a bottleneck. For hot paths, give each goroutine its own *rand.Rand.
Before Go 1.20, math/rand was seeded with the value 1 unless you called rand.Seed yourself. That meant code like the product-of-the-day picker above would print the same product every run, which is rarely what anyone wants.
The historical workaround was to seed at program start, usually from the current time:
Then Go 1.20 changed the default: the global source is now seeded with a random value at program start, so a fresh program produces a fresh sequence without any work from you. rand.Seed still works, but it's deprecated in favor of constructing your own *rand.Rand when you need a controlled seed.
Why did this take so long? Because changing the default broke any test that relied on the deterministic sequence the old default produced. The Go team accepted the breakage in 1.20 because the cost of confusing newcomers was higher than the cost of fixing a few tests.
If you need a reproducible sequence (for tests, simulations, or anywhere repeatability matters), construct a Rand with an explicit seed:
Run this program a hundred times and you'll get the same three numbers every time, because rand.New(rand.NewSource(42)) builds a generator with a fixed starting state. This is the behavior you want when a test needs to assert "the shuffle produced this order" or when a simulation needs to be reproducible from a logged seed.
Go 1.22 introduced math/rand/v2, a redesigned API that fixes the warts in the original package. New code should prefer it. The v1 package still exists for backwards compatibility, but the v2 package has cleaner naming, better default algorithms, and removes the deprecated functions.
What changed:
| Concern | math/rand (v1) | math/rand/v2 |
|---|---|---|
Function name for [0, n) int | Intn | IntN |
Generic [0, n) helper | none | rand.N[T] for any integer type |
| Default generator algorithm | Lagged Fibonacci | PCG (better statistical properties) |
Seed function | exists, deprecated | removed entirely |
| Auto-seeded global | since Go 1.20 | from day one |
Here's the v2 version of the same product-of-the-day picker:
The only visible change is IntN (capital N) instead of Intn. The capital is a deliberate signal that this is the v2 API. The behavior is the same, but the global source is always auto-seeded and the underlying algorithm is PCG, which has better statistical properties than the older Lagged Fibonacci generator.
The new rand.N function is the v2 highlight. It's a generic helper that returns a random value in [0, n) for any integer type:
The first call works directly with time.Duration (which is an int64 internally). The second uses uint32 and shows the type-parameter syntax. In v1 you'd have to write time.Duration(rand.Int63n(int64(500 * time.Millisecond))), which is ugly enough that people skipped jitter altogether.
For reproducible sequences in v2, use rand.New(rand.NewPCG(seed1, seed2)):
PCG (Permuted Congruential Generator) takes two 64-bit seeds. Same seeds, same sequence, every time. The algorithm is faster than the v1 default and has stronger statistical guarantees, which is why the Go team made it the v2 default.
Cost: v2's IntN is also slightly faster than v1's Intn because the PCG algorithm has cheaper internal state transitions. For high-throughput code, the v2 package is a small but free win.
The top-level functions in both math/rand and math/rand/v2 are safe for concurrent use, but they pay for that safety with a sync.Mutex on the global generator. For most code, that mutex is invisible. For code that calls rand.Intn from thousands of goroutines per second, the mutex becomes a real bottleneck.
The fix is to give each goroutine its own *rand.Rand. A *rand.Rand instance is not safe for concurrent use, which is exactly what you want: no internal locking, so calls are as fast as possible, with the constraint that you don't share the instance between goroutines.
Each worker generates its own sequence with no contention on a shared lock. The trade is that you have to thread the *rand.Rand through your code instead of just calling rand.IntN from anywhere.
For most applications, the global generator is fine. The mutex contention only becomes visible at very high call rates, and pre-optimizing for it is a waste. Use local generators when profiling shows the mutex is hot, or when you're writing a library that may be called in tight loops.
Cost: the global mutex serializes all goroutines that call rand.IntN simultaneously. At low rates this is invisible. At high rates (think Monte Carlo simulations spread across all CPU cores), throughput collapses. Switch to per-goroutine generators when you measure the contention.
Shuffling the order of recommended products on a homepage so different users see different orderings is a common use case. rand.Shuffle handles this directly.
Shuffle uses the Fisher-Yates algorithm, which produces a uniformly random permutation in O(n) time. The callback receives two indices and is expected to swap whatever data structure you're shuffling. That callback-based API is what makes it work for any sliceable type without generics.
If you just want a random permutation of integers (useful for sampling without replacement), rand.Perm(n) returns a slice with the values 0 through n-1 in random order:
Slicing the result gives you a random sample without duplicates. For larger catalogs this is wasteful because you generate the full permutation even when you only want a few, but for samples up to a few thousand items it's idiomatic and fast enough.
crypto/rand is a completely different package with a completely different job. It reads from the operating system's entropy source (/dev/urandom on Linux and macOS, BCryptGenRandom on Windows), which means the output is unpredictable even to an attacker who can see other output from the same process.
The core function is rand.Read, which fills a byte slice with random bytes:
The call returns an error. crypto/rand.Read almost never fails on modern systems because the OS entropy pool is always available, but check the error regardless. If it does fail, something is deeply wrong with the system, and continuing with non-random bytes would be worse than crashing. Raw bytes are rarely the output format you want; tokens and IDs are usually hex or base64 strings.
Cost: crypto/rand.Read is slower than math/rand because it reads from the OS entropy pool, which involves a syscall on most platforms. For tokens (a handful per request), the cost is negligible. For millions of values per second, you wouldn't use it anyway, because high-rate generation isn't what it's for.
Unlike math/rand, crypto/rand.Read is safe for concurrent use without any extra locking. The package's Reader is backed by the OS, which handles concurrency internally.
Raw random bytes aren't useful as-is in URLs, headers, or database columns. You need to encode them into a string. The standard choices are hex and base64.
For an order confirmation code that goes into an email and a URL:
8 random bytes encode to 16 hex characters, which is short enough for an email but long enough that guessing one is computationally infeasible (2^64 possibilities). Hex is the most readable encoding; every byte becomes exactly two characters, all in the alphabet 0-9a-f.
For a password reset token that goes into a URL, base64 is more compact:
32 random bytes (256 bits) is the standard size for a strong token. RawURLEncoding is base64 with URL-safe characters (- and _ instead of + and /) and no = padding, which makes it safe to drop straight into a URL without further encoding.
| Encoding | Bytes per char | URL-safe? | Padding? | Use for |
|---|---|---|---|---|
hex | 2 hex chars per byte | yes | n/a | short codes, anything case-insensitive |
base64.StdEncoding | ~1.33 chars per byte | no (+, /) | yes (=) | binary in JSON, opaque blobs |
base64.URLEncoding | ~1.33 chars per byte | yes (-, _) | yes (=) | URL-safe with padding |
base64.RawURLEncoding | ~1.33 chars per byte | yes (-, _) | no | URL-safe tokens, JWT components |
For most session IDs and tokens, base64.RawURLEncoding of 32 bytes is the right default. It's compact, URL-safe, and gives you 256 bits of entropy.
Session ID generation in a typical web app:
256 bits of entropy from the OS, encoded into a 43-character URL-safe string. Storing this server-side as the session key is the standard pattern for cookie-based authentication.
When you need a uniformly random integer (not just random bytes), crypto/rand.Int is the function. It takes a *big.Int upper bound and returns a value in [0, max):
This is the right way to generate a cryptographically random verification code. The naive approach of taking 4 random bytes and computing int(b) % 1_000_000 introduces a small but real bias because 2^32 % 1_000_000 is not zero, which means some values are slightly more likely than others. crypto/rand.Int uses rejection sampling internally to give a uniform distribution.
The *big.Int return type is awkward for small numbers, but n.Int64() extracts the value as an int64 when you need it. The big.Int exists because crypto/rand.Int can generate values up to arbitrary sizes, which is needed for RSA key generation and similar cryptographic primitives.
Here is the dangerous pattern:
What's wrong with this code?
It looks fine. The token is 32 characters long, randomly chosen from a 36-character alphabet, which is 36^32 possible tokens. That's an enormous number, and brute-forcing one looks infeasible.
The bug is that math/rand is a PRNG with a small state. The default generator in v1 has 64 bits of internal state, the v2 PCG has 128 bits. An attacker who can observe a handful of tokens generated by your service can recover the internal state of the generator and predict every future token. There's published research and open-source tooling for this exact attack. The token length and alphabet size are irrelevant because the underlying entropy is the generator's state, not the output space.
Fix:
crypto/rand.Read produces bytes that come from the OS entropy pool, which an attacker cannot predict from observing other outputs. The token is now genuinely 256 bits of entropy.
The same bug applies to session IDs, API keys, CSRF tokens, salts, IVs, and anything else that must be unguessable. If an attacker guessing the value causes a security problem, use crypto/rand. There is no scenario where math/rand is the right choice for a security-sensitive value.
Bucketing users for A/B tests is a case where math/rand would technically work, but a better pattern exists. The problem is that the same user might land in different buckets across sessions, which makes test results noisy.
The idiomatic pattern is stable hashing: hash the user ID, take the result modulo the bucket count, and you get a stable bucket assignment that doesn't change between sessions.
Same user, same bucket, every time. This isn't really random in the usual sense; it's a deterministic distribution that looks random across users. For A/B testing, that property matters more than statistical randomness, so reach for hashing instead of math/rand. When you genuinely want a random per-event assignment (logging sampling, for example), math/rand is fine.
When choosing between the two packages, this table summarizes the decision.
| Use case | Package | Why |
|---|---|---|
| Daily product of the day | math/rand/v2 | No attacker advantage in guessing |
| Random discount roll | math/rand/v2 | Not security-sensitive |
| Shuffle product recommendations | math/rand/v2 | Speed and ease of use |
| Generate fake data for tests | math/rand/v2 | Reproducibility (seed it) |
| Simulation jitter | math/rand/v2 | Speed; rand.N(time.Duration) is clean |
| Game outcome / dice roll | math/rand/v2 | Unless it's gambling, then crypto/rand |
| Order confirmation code | crypto/rand | Guessable codes enable order takeover |
| Session ID | crypto/rand | Guessable session IDs = account takeover |
| Password reset token | crypto/rand | The classic security bug |
| API key | crypto/rand | Unpredictable, high entropy |
| Salt for password hashing | crypto/rand | Predictable salts defeat hashing |
| IV for AES | crypto/rand | Predictable IVs break the cipher |
| CSRF token | crypto/rand | Predictable tokens allow forgery |
| A/B test bucket assignment | hash of user ID | Stability across sessions matters more than randomness |
The middle column is the entire decision rule, condensed.
The diagram puts numbers on the speed gap. math/rand/v2 is roughly an order of magnitude faster than crypto/rand, which matters in tight loops but is invisible for token generation, where you generate a handful of values per request.
10 quizzes