← Writing
The Editorial · Engineering

Random numbers: uniform, Gaussian, and why you never mod a float

Issue 009Jul 16, 202610 min read

a + Math.random() * (b - a) works because it's a linear rescaling of a uniform variable. ((Math.random() * 1000) % (b - a)) + a doesn't, because folding an uneven pool with modulo introduces bias — and shifting by a afterward doesn't fix it. The actual math behind both — plus the Gaussian, the other distribution everyone reaches for.

The issue

Say you need a random 3-digit number somewhere between a and b. The tempting one-liner is ((Math.random() * 1000) % (b - a)) + a — multiply by 1000 for three digits, mod by the width to fit inside the range, then shift up by a so it starts in the right place. It's wrong, in two separate ways, and it's worth being precise about both — and about why a + Math.random() * (b - a) is the version that actually works.

What a random number actually is

Nothing from Math.random() is random in the dice-roll sense — it's a pseudo-random number generator (PRNG), a deterministic algorithm that gives the same next value for the same internal state. V8 (Chrome, Node, Edge) seeds one with OS entropy once, then iterates xorshift128+ for every call after that — arithmetic on a 128-bit state, statistically indistinguishable from randomness unless you're specifically looking for the pattern.

It's engineered to behave like a draw from the continuous Uniform(0, 1) distribution: every value between 0 and 1 equally likely, at the resolution of a 64-bit float — 53 bits of mantissa, so about 2⁵³ (9 quadrillion) equally spaced points. Fine enough to treat as a true continuum for dice, shuffles, IDs, or sampling.

Uniform: every outcome, equally likely

A distribution is a rule for how likely each value is. Uniform(a, b) is the simplest: flat probability across the interval — density 1 / (b − a) inside it, zero outside. Every sub-interval of the same width has the same chance, no matter where you put it.

Uniform(a, b)Math
density:   f(x) = 1 / (b − a)     for a ≤ x ≤ b
           f(x) = 0                 otherwise

mean:      (a + b) / 2
variance:  (b − a)² / 12

Scaling a uniform variable keeps it uniform

Here's why a + Math.random() * (b - a) is correct, not just conventional. If X is Uniform(0, 1), an affine transform — multiply by a constant, add a constant — keeps it exactly uniform, just stretched and shifted. Y = a + (b − a)·X lands anywhere in [a, b), and because the transform is linear, it stretches every sub-interval of [0, 1) by the same factor. Flat goes to flat.

Math.random() → Uniform(0, 1)01xa + (b − a) · x→ Uniform(a, b)aby
Fig 1Math.random() draws a point uniformly on [0, 1). The affine transform a + (b − a)·x stretches that same point onto [a, b) — linearly, so uniform stays uniform.AI-generated figure

The CDF proves it in one line: P(Y ≤ y) = P(X ≤ (y−a)/(b−a)) = (y−a)/(b−a) — exactly the CDF of Uniform(a, b). Exact for every draw, because it's a straight line doing the transforming.

the two recipes that are actually correctJavaScript
// a float uniformly in [a, b)
const f = a + Math.random() * (b - a);

// an integer uniformly in [a, b], inclusive on both ends
const n = a + Math.floor(Math.random() * (b - a + 1));

Where "multiply, then mod" goes wrong

This version does at least try to account for a: mod by the width (b - a) to get something in [0, b - a), then add a to slide the whole thing into place. It's still wrong, for two reasons. First: % in JavaScript is a true remainder, not integer division — Math.random() * 1000 is a float like 483.7291…, and 483.7291 % 7 is 6.729…, not the integer you pictured. You'd need Math.floor() first for the mod to mean anything.

Second, the one that survives the floor(): 1000 has nothing to do with (b - a). Reducing a fixed pool of integers modulo a number that doesn't evenly divide it is modulo bias — easiest seen with a deliberately dramatic example.

0–999, folded modulo 600residues 0–399400–599same fold — the low residues land twice as often
Fig 21000 possible draws (0–999), folded down modulo 600. The low 400 residues get hit by two different input numbers each; the top 200 only ever get hit by one. They are not equally likely — one bucket is twice as likely as the other.AI-generated figure

Say a = 200 and b = 800, so the width b - a is 600. Math.floor(Math.random() * 1000) % 600 maps numbers 0–599 to residues 0–599, but 600–999 map to residues 0–399 again — 600 mod 600 is 0, 999 mod 600 is 399. So residues 0–399 each come from two inputs out of 1000; residues 400–599 come from only one. The trailing + a doesn't fix any of that — it just relabels the same skew: residue 0 becomes output value 200, residue 550 becomes 750, and 200 is still twice as likely to come out as 750.

the bias isn't theoretical — it countsJavaScript
const a = 200, b = 800; // width: 600
const counts = new Array(b - a).fill(0);
for (let i = 0; i < 1_000_000; i++) {
  const n = (Math.floor(Math.random() * 1000) % (b - a)) + a;
  counts[n - a]++;
}
console.log(counts[0]);   // ≈ 2000 — n = 200, hit by both 0 and 600 before the shift
console.log(counts[550]); // ≈ 1000 — n = 750, hit only once before the shift

Even a width that looks harmless bites: 1000 % 7 is 6, so a 7-wide range gives its first six values a 143-in-1000 chance each and the seventh only 142-in-1000 — a 0.7% skew no eyeball test catches. It's always biased whenever the pool size isn't an exact multiple of the range's width; only the size of the skew changes.

Why the float version mostly dodges this

So why does a + Math.random() * (b - a) get away with it? Not because floats are immune to modulo bias — because it isn't modulo reduction at all. Multiplying and adding is a linear rescaling of a continuum: no folding, no divisibility condition, because there's nothing to divide. The two stories do meet once you Math.floor() the result into a whole number — now you're bucketing a discrete set too. But the grid is ~2⁵³ points wide, so even an uneven split among a few hundred buckets misplaces probability by about one part in 2⁵³ — undetectable. Old rand() % n in C, with RAND_MAX often just 32767, produces a bias you can find with a spreadsheet. Same mechanism, wildly different scale.

The other distribution: Gaussian

Uniform isn't the only distribution worth knowing — just the one Math.random() gives you for free. Gaussian (normal) is the bell curve: values cluster near a mean μ and taper off symmetrically at a rate set by σ. Uniform says every value is equally plausible; Gaussian says values near the middle are common and extremes get rarer — a better model for height, measurement error, or exam scores than a flat line.

uniformflat — every value equalgaussianμpeaked — clusters near μ
Fig 3The two distributions side by side. Uniform is flat — Math.random() gives you this one directly. Gaussian clusters around a mean and tapers at the tails — it takes a transform (or the Central Limit Theorem) to get here from a uniform source.AI-generated figure
Gaussian(μ, σ)Math
density:   f(x) = (1 / (σ√(2π))) · e^(−(x−μ)²/(2σ²))

mean:      μ
variance:  σ²
~68% of the mass within ±1σ, ~95% within ±2σ, ~99.7% within ±3σ

Gaussians show up everywhere because of the Central Limit Theorem: sum enough independent effects, however each is distributed, and the sum converges toward a Gaussian — the default shape for a noisy real-world quantity, and the wrong one for a die roll or a shuffled deck, which really are uniform. But Math.random() only ever hands you uniform; turning that into a Gaussian sample takes an explicit transform — Box–Muller: feed it two independent Uniform(0, 1) draws, get one exact standard-normal sample out.

Box–Muller — uniform in, Gaussian outJavaScript
function gaussianRandom(mean = 0, stdDev = 1) {
  let u = 0, v = 0;
  while (u === 0) u = Math.random(); // 0 would make log(u) = -Infinity
  while (v === 0) v = Math.random();
  const z = Math.sqrt(-2 * Math.log(u)) * Math.cos(2 * Math.PI * v);
  return z * stdDev + mean;
}

The actual recipes

It collapses to one habit of mind: know which operation you're performing on the distribution. Multiply-and-add is a line stretched across a continuum — nothing to fold. Modulo is a fold: it collapses a set of size M onto n buckets, and unless M is an exact multiple of n, some buckets catch more of the fold than others. Every "obviously fine" random-number bug — including the one this issue opened with — is that same fold, showing up somewhere you didn't expect.

All issues