Ask ten C tutorials how to roll a die and ten will hand you rand() % 6. That line is not wrong so much as unexamined: whether it is fine or badly broken depends on a number most programmers have never checked, and the usual advice to “avoid modulo bias” is repeated far more often than it is measured.
This guide measures it. Every program below was compiled and run for this article on Ubuntu 24.04 with GCC 13.3 — the C examples under both -std=c11 and -std=c17, the C++ examples under -std=c++17, all with -Wall -Wextra and zero warnings. Every output block, distribution count and timing figure is captured verbatim from those runs, including a 20,000,000-sample bias measurement and a 50,000,000-draw benchmark. Where a claim concerns a platform I could not test — MSVC’s runtime, MinGW’s random_device — it is labelled as such rather than presented as measured.
Table of Contents
- The Short Answer
- What Is a Pseudo-Random Number Generator?
- rand() and srand(): The C Standard Library Approach
- The Three Ways rand() Goes Wrong
- Generating a Number in a Range Correctly
- Modern C++: The <random> Library
- When You Need Secure Randomness
- Quick Reference
- Key Takeaways
- Frequently Asked Questions
- Conclusion
The Short Answer
If you want the correct line and the reasoning later, take these.
C — a die roll, 1 to 6:
#include <stdio.h>
#include <stdlib.h>
#include <time.h>
int main(void) {
srand((unsigned)time(NULL)); /* seed ONCE, at startup */
int roll = 1 + rand() % 6;
printf("%d\n", roll);
return 0;
}
C++ — the same thing, done properly:
#include <iostream>
#include <random>
int main() {
std::random_device rd; // one-off entropy source
std::mt19937 gen(rd()); // seed the engine once
std::uniform_int_distribution<int> die(1, 6); // no modulo, no bias
std::cout << die(gen) << '\n';
}
In C++ the second version is not merely tidier. It is unbiased by construction, and — as measured later in this article — it is roughly six times faster than std::rand(). If you write C++, there is no remaining argument for rand().
What Is a Pseudo-Random Number Generator?
A pseudo-random number generator (PRNG) is an algorithm that produces a sequence of numbers that appears random but is completely determined by an initial value called the seed. Given the same seed, a PRNG always produces the same sequence. C’s rand() and C++’s std::mt19937 are both PRNGs: they are fast and repeatable, which makes them ideal for games, simulations and tests — and unsuitable for passwords, tokens or keys.
The determinism is a feature, not a defect. It is what lets you replay a simulation, reproduce a failing test, or regenerate the same procedural landscape from a saved seed. What matters is knowing which properties you are relying on: statistical quality (does the output look uniform?) is a different question from cryptographic quality (can an attacker predict the next value?). A generator can be excellent at the first and useless at the second — Mersenne Twister is exactly that.
For the precise guarantees the standard makes, cppreference’s rand() page is the reference; it is notably candid that rand() quality is implementation-defined.
rand() and srand(): The C Standard Library Approach
C gives you two functions in <stdlib.h>. rand() returns an int in [0, RAND_MAX], and srand(unsigned) sets the seed.
The first thing to check is a number almost no tutorial prints:
printf("RAND_MAX on this platform: %d\n", RAND_MAX);
On our test machine (glibc, x86-64) this gives:
RAND_MAX on this platform: 2147483647
The C standard guarantees only that RAND_MAX is at least 32767. glibc gives you 2,147,483,647. Microsoft’s runtime gives you 32,767 — the minimum. That single difference decides whether the % 6 you just wrote is harmless or measurably skewed, which is why the next section matters.
Without a call to srand(), the sequence is seeded with 1, so an unseeded program prints the same “random” numbers on every run. That is the behaviour the classic srand(time(NULL)) idiom exists to fix — and it introduces a problem of its own.
The Three Ways rand() Goes Wrong
1. Modulo bias — real, but not always
The standard warning is that rand() % n skews the distribution because RAND_MAX + 1 is rarely a multiple of n. True. The part that is almost never stated is how much, and the honest answer is that it depends entirely on the ratio between n and RAND_MAX.
We measured both regimes over 20,000,000 samples each.
With glibc’s 31-bit RAND_MAX, rolling a die:
A. rand() % 6 with RAND_MAX=2147483647
0: 3334884 (0.0465% deviation from uniform)
1: 3335730 (0.0719% deviation from uniform)
2: 3334025 (0.0207% deviation from uniform)
3: 3332458 (-0.0263% deviation from uniform)
4: 3332516 (-0.0245% deviation from uniform)
5: 3330387 (-0.0884% deviation from uniform)
Every deviation is under 0.09% — indistinguishable from sampling noise. For a die roll on this platform, rand() % 6 is fine. Articles that call this a serious bug are overstating it.
Now the same code where RAND_MAX is 32767 — Microsoft’s value, and common on embedded libcs:
B. rand() % 10000 with a 15-bit RAND_MAX (32767)
values 0..2767 : 6755940 (33.78% of draws)
values 2768..9999: 13244060 (66.22% of draws)
uniform would give 27.68% / 72.32%
-> low values are 1.22x more likely than they should be
That is a real defect. Values below 2768 turn up 22% more often than they should, forever, in every run.
The arithmetic explains it exactly. 32768 = 3 × 10000 + 2768, so the draws 0..32767 fold onto 0..9999 unevenly: buckets 0–2767 get four chances each, buckets 2768–9999 get three.
Working it out on paper predicts 33.7891%. We measured 33.78%. The theory and the measurement agree to two decimal places, which is a good sign that the model is right rather than the numbers being coincidental.
The rule to remember: modulo bias scales with n / RAND_MAX. Small ranges on a 31-bit RAND_MAX are safe in practice. Large ranges, or any range on a 15-bit RAND_MAX, are not. Since portable code cannot assume which one it gets, the safe habit is to avoid raw % for anything but throwaway code.
2. Seeding with time(NULL) is coarser than you think
time(NULL) advances once per second. Anything that starts more than once inside the same second gets the same seed — and therefore the same “random” numbers. Three consecutive runs of the same program:
=== Same program run 3x in rapid succession (same wall-clock second) ===
seed=time(NULL) -> first three: 44 19 46
seed=time(NULL) -> first three: 44 19 46
seed=time(NULL) -> first three: 44 19 46
=== Now with a 1-second gap ===
seed=time(NULL) -> first three: 44 19 46
seed=time(NULL) -> first three: 86 75 60
Identical output three times over. This bites batch jobs, test harnesses, CGI-style request handlers and anything launched in a loop by a shell script. It is also the reason srand() belongs exactly once at program start — calling it before every rand(), a surprisingly common mistake, reseeds from the same second repeatedly and can pin the output to a single value.
3. It is predictable, and that is not a bug you can patch
rand() is designed to be reproducible. An attacker who learns the seed — and time(NULL) at the moment of launch is a small, guessable space — can reproduce every number you will generate. No amount of extra shuffling fixes this, because the weakness is the algorithm, not the usage. Secure randomness is a different mechanism entirely, covered near the end of this article.
Generating a Number in a Range Correctly
The formula everyone copies uses the modulus operator: lower + rand() % (upper - lower + 1). Two things go wrong with it in the wild.
The first is a transcription error common enough to be worth naming: computing the span and then forgetting to add lower back, or folding lower into the modulus. That produces code which looks right, passes a casual test when lower happens to be 0, and silently ignores the lower bound the moment it is not. Tested with lower = 50, upper = 100:
requested range : 50..100
actual range : 0..100 <-- WRONG
The second is the bias above. Both are solved by rejecting the unusable tail of the range instead of folding it:
/* Uniform value in [0, bound) with no modulo bias. */
int rand_below(int bound) {
/* Largest multiple of `bound` that fits in [0, RAND_MAX]. */
int limit = RAND_MAX - (RAND_MAX % bound);
int r;
do {
r = rand();
} while (r >= limit); /* discard the biased tail */
return r % bound;
}
/* Inclusive range [lower, upper]. */
int rand_range(int lower, int upper) {
return lower + rand_below(upper - lower + 1);
}
Re-running the 15-bit test through the corrected function:
Unbiased rand15_below(10000), 20000000 samples:
values 0..2767: 27.6996% of draws
uniform target: 27.6800%
(biased version measured 33.78% -- see the earlier test)
33.78% becomes 27.70% against a target of 27.68%. The bias is gone. The loop looks like it might run forever, but it discards at most a fraction of draws — the expected number of iterations is under two for any sensible bound.
Modern C++: The <random> Library
C++11 replaced the whole approach. Instead of one function doing two jobs badly, <random> separates them:
- an engine produces raw random bits (
std::mt19937,std::minstd_rand,std::mt19937_64) - a distribution maps those bits onto the shape you want (
std::uniform_int_distribution,std::normal_distribution)
The separation is the point. The distribution handles the range correctly — no modulo, no bias, no rejection loop you have to write yourself.
#include <iostream>
#include <random>
int main() {
std::random_device rd;
std::mt19937 gen(rd());
std::uniform_int_distribution<int> die(1, 6);
std::uniform_real_distribution<double> unit(0.0, 1.0);
std::normal_distribution<double> bell(100.0, 15.0);
std::cout << "die: " << die(gen) << '\n'
<< "uniform: " << unit(gen) << '\n'
<< "normal: " << bell(gen) << '\n';
}
Checking uniform_int_distribution(1, 6) over six million rolls:
4. uniform_int_distribution(1,6) over 6000000 rolls:
1: 1000211 (0.0211% deviation)
2: 998491 (-0.1509% deviation)
3: 1000737 (0.0737% deviation)
4: 1000091 (0.0091% deviation)
5: 1000633 (0.0633% deviation)
6: 999837 (-0.0163% deviation)
Flat, as promised — and correct by construction on every platform, regardless of what RAND_MAX happens to be.
Choosing an engine
Three engines cover nearly everything, and the performance ordering surprises people:
6. Throughput:
mt19937 : 140 ms per 50M draws
mt19937_64 : 137 ms per 50M draws
minstd_rand : 254 ms per 50M draws
std::rand() : 952 ms per 50M draws
std::mt19937 is roughly 6.8 times faster than std::rand() in this test, and the figures held within a few percent across three consecutive runs. The reason is not that Mersenne Twister is a simpler algorithm — it is that glibc’s rand() is thread-safe and pays for internal locking on every call, while an mt19937 object you own has no such overhead.
This matters because “the C library is faster because it’s simpler” is the most common defence of rand() in C++ code, and on this platform it is exactly backwards.
| Engine | Speed | State size | Use it for |
|---|---|---|---|
std::mt19937 | Fast | 2.5 KB | The sensible default for simulations, games, tests |
std::mt19937_64 | Fast | 5 KB | When you need 64-bit output |
std::minstd_rand | Slower here | 8 bytes | Memory-constrained code; weaker statistical quality |
std::default_random_engine | Varies | Varies | Avoid — the standard does not say which engine you get |
Two of those deserve a note. std::default_random_engine is an alias whose target is implementation-defined, so identical code can produce different sequences on different compilers — name the engine you want instead. And std::minstd_rand is a linear congruential generator: compact, but with the well-known LCG weakness that low-order bits cycle quickly.
Seeding correctly
std::random_device is the standard’s entropy source, intended to seed engines rather than to be used directly. On our test machine it reports genuine entropy:
2. random_device entropy: 32 (0.0 means it may be a deterministic fallback)
The standard permits an implementation to fall back to a deterministic engine if no real entropy source exists, which produced a well-known trap: old MinGW-w64 builds returned an identical sequence every run. That has been fixed since GCC 9.2, which switched to rand_s, so it is a historical footnote rather than current advice — but plenty of pages still repeat the warning as though it were live. Checking rd.entropy() costs nothing if you want to be defensive; note that the value itself is not always meaningful either.
For heavier seeding, one unsigned is a thin seed for an engine with 19,937 bits of state. std::seed_seq spreads more entropy across it:
std::random_device rd;
std::seed_seq seq{rd(), rd(), rd(), rd()};
std::mt19937 gen(seq);
Reproducibility and thread safety
Fixing the seed makes a run repeatable — which is what you want for tests, replays and procedural generation:
3. Two engines seeded with 42 produce identical output: yes
Distributions can carry internal state, so reset one with dist.reset() if you need a byte-identical replay after reuse.
On threading, the rule is simple: engines are not thread-safe, and sharing one across threads is a data race. Give each thread its own engine, seeded distinctly — thread_local is the usual mechanism. C’s rand() has the same problem in a worse form, since the state is global and hidden; POSIX offers rand_r(), but it is obsolescent and its tiny state makes it a poor choice regardless.
When You Need Secure Randomness
Everything above is the wrong tool for session tokens, password salts, API keys, nonces or anything an adversary benefits from predicting. Mersenne Twister is statistically excellent and cryptographically worthless: observing 624 consecutive outputs is enough to reconstruct its entire internal state and predict every future value.
Use the operating system’s CSPRNG instead. On Linux, getrandom():
#define _GNU_SOURCE
#include <stdio.h>
#include <sys/random.h> /* getrandom(), glibc 2.25+ */
int main(void) {
unsigned char token[16];
if (getrandom(token, sizeof token, 0) != (ssize_t)sizeof token) {
perror("getrandom");
return 1;
}
for (size_t i = 0; i < sizeof token; i++) printf("%02x", token[i]);
printf("\n");
return 0;
}
Two runs, showing there is no reproducibility here by design:
16 secure random bytes: f2bba57e5e3951257c4965f04880c02d
16 secure random bytes: c41533a8d533b9c78d9971a59181ba4d
| Platform | Use | Notes |
|---|---|---|
| Linux | getrandom() | glibc 2.25+; reads the kernel CSPRNG |
| Windows | BCryptGenRandom() | Replaces the deprecated CryptGenRandom |
| macOS / BSD | arc4random_buf() | Simple API, no error path to handle |
| Portable C++ | libsodium randombytes_buf() | Third-party, but consistent everywhere |
Note what is not in that table: std::random_device. Although some implementations back it with a real CSPRNG, the standard does not require it, so it is not a portable guarantee for security-critical work. Use the platform call.
Quick Reference
| Task | C | C++ |
|---|---|---|
| Seed once at startup | srand((unsigned)time(NULL)); | std::mt19937 gen(std::random_device{}()); |
Integer in [a, b] | rand_range(a, b) (rejection, above) | std::uniform_int_distribution<int>{a, b}(gen) |
Real in [0, 1) | rand() / (RAND_MAX + 1.0) | std::uniform_real_distribution<double>{0, 1}(gen) |
| Normal distribution | Hand-rolled Box–Muller | std::normal_distribution<double>{mu, sigma}(gen) |
| Reproducible run | srand(42); | std::mt19937 gen(42); |
| Security-critical | getrandom() / BCryptGenRandom() | Same — not <random> |
Key Takeaways
- Check
RAND_MAXbefore trustingrand() % n. At 2,147,483,647 the bias on small ranges is under 0.09% and irrelevant; at 32,767 we measured low values arriving 1.22× too often. srand(time(NULL))has one-second granularity. Programs launched repeatedly within the same second produce identical sequences — demonstrated above.- Seed once, at startup. Reseeding before every draw is a common bug that destroys the sequence.
- In C++,
<random>is both correct and fast.std::mt19937measured ~6.8× faster thanstd::rand(), because glibc’srand()pays for thread-safety locking. - Name your engine.
std::default_random_engineis implementation-defined and will not reproduce across compilers. - PRNGs are never acceptable for secrets. Mersenne Twister’s state is recoverable from 624 outputs; use the OS CSPRNG for anything an attacker cares about.
Frequently Asked Questions
Conclusion
The interesting thing about rand() % 6 is that the confident advice on both sides is wrong. It is not the catastrophe that “never use modulo” implies — on a 31-bit RAND_MAX the skew is unmeasurable in practice. It is also not safe, because the moment your code compiles somewhere with a 15-bit RAND_MAX, the same line quietly becomes a 22% distortion. The defensible position is to know which regime you are in, and to write code that does not depend on the answer.
For C, that means keeping rand() for casual work and reaching for rejection sampling when the range is wide or the platform is unknown. For C++, the decision is easier than the folklore suggests: <random> is correct by construction and, on the evidence here, considerably faster than the thing it replaced. The remaining rule is the one worth carrying furthest — no pseudo-random generator, however good its distribution, belongs anywhere near a secret. That job belongs to the operating system, and it is the kind of distinction worth getting right early.



