Skip to content
Allin

Shuffling Is Harder Than It Looks: A Million Runs of the One-Line Shuffle

Published 6/11/2025 · 13 min read · Everyday calculators

Lena Hoffmann

Lena HoffmannScience & education writer at Allin

Mathematics · Physics

Checked against 4 sources

View profile
In short

Sorting an array with a random comparator does not shuffle it. Run a million shuffles of the four items A, B, C, D through `array.sort(() => Math.random() - 0.5)` and the twenty-four possible orderings should each turn up about 41,667 times. They do not. On Node 26.3.0 (V8 14.6) the identity order ABCD came back 62,485 times and DBCA only 30,998 — a ratio of 2.03 to 1, and a chi-square statistic of 125,397 on 23 degrees of freedom against a 5% critical value of 27.32. The structure is not noise: exactly eight of the twenty-four orderings land at probability 1/16 and the other sixteen at 1/32, a model that fits four million further runs with a chi-square of 19.7. The same million runs through Fisher-Yates gave a chi-square of 10.3, comfortably inside chance. The cause is that a comparator returning a random sign is not a consistent ordering, so the output depends on the internals of the sort algorithm — which makes the bias engine-specific and version-specific rather than merely small. Fisher-Yates is three lines, exact for every array length, and has no such dependency. Use it, draw the swap index inclusively, and reject the modulo shortcut when you map a random integer onto a range.

The shuffle everyone writes — sorting with a random comparator — is biased, and not slightly. A million measured runs show eight of the twenty-four orderings turning up twice as often as the other sixteen.

A million shuffles of four items

Four items have twenty-four orderings. That is small enough to count exhaustively, which is what makes it the right test case: run a shuffle a million times, tally how often each of the twenty-four comes out, and a fair shuffle has to put roughly 41,667 in every bucket. Anything that systematically favours some orderings will show up as a lump, and one number — the chi-square statistic — turns the whole table into a single verdict. With 23 degrees of freedom, a fair method scores around 23, and anything above 27.32 would happen less than 5% of the time by chance alone.

The one-liner scored 125,397. Not 30, not 300 — five thousand times the threshold. The commonest ordering, BADC, came up 62,810 times; the rarest, DBCA, 30,998. The original order ABCD survived intact 62,485 times, half again as often as it should. Running the same million through a correctly written Fisher-Yates shuffle produced 10.3, which is what a fair method looks like: the extreme counts were 41,434 and 41,879, a spread of about 1%. Stretching the test to five items made the gap wider still — 229,683 against 89.0 — because every extra element gives the sort algorithm one more decision to make badly.

Not twenty-four probabilities — two

The counts are not scattered. Sorted, they fall into two tight clusters: eight orderings around 62,500 and sixteen around 31,250. Those are exactly 1/16 and 1/32 of a million, and the arithmetic closes — 8 × (1/16) + 16 × (1/32) = 1. Four million further runs tested that hypothesis directly and produced a chi-square of 19.7 on 23 degrees of freedom, which is a good fit. So the one-line shuffle on this engine is not approximately uniform with a wobble; it is a two-valued distribution in which half your probability mass is crammed into a third of the outcomes.

The eight favoured orderings share a property worth noticing: ABCD, ABDC, ADBC, BACD, BADC, BDAC, DABC and DBAC all keep C out of the first two positions. That is not mysticism, it is the sort algorithm leaking through. A four-element array is short, so V8 never leaves its binary-insertion path; the comparator gets called 4.5 times on average, and 4.5 coin flips cannot possibly generate 24 equally likely outcomes, because 24 does not divide any power of two. The bias is baked into the shape of the decision tree before randomness is even involved.

The bias belongs to the engine, not to the language

ECMA-262 requires the comparison function passed to sort to be a consistent comparator: it must be transitive, it must be antisymmetric, and it must give the same answer for the same pair every time. A comparator built on Math.random breaks all three within a single call. The specification's response is not to define what happens; it is to say that if the comparator is inconsistent, the result of the sort is implementation-defined. That single sentence is the whole story. You have not written a shuffle with unusual statistics — you have written a program whose output the standard declines to specify.

The practical consequence is that these counts are a measurement of one engine at one version, not a universal constant. V8 has changed its sort more than once; a run on a different engine, or on the same engine after an upgrade, will produce a different lumpy distribution, and an array long enough to trip the merge path will produce a different one again. Nothing in the standard forbids that, and nothing warns you when it happens. A shuffle whose statistical properties move when the runtime is patched is not a shuffle you can test.

Fisher-Yates, and the off-by-one that ruins it

The correct algorithm — Knuth's Algorithm P — walks the array from the last index down to the second, and at each position i draws a random index j uniformly from 0 to i inclusive, then swaps positions i and j. Three details carry the whole proof. The loop descends. The draw includes i itself, so an element is allowed to stay where it is. And the range shrinks by one each iteration, so the number of possible execution paths is n × (n−1) × … × 2 = n!, which is exactly the number of permutations. A bijection between execution paths and outcomes is what uniformity means, and it holds for every n, not just for the ones you tested.

Change one character and it breaks. The common variant walks the array upwards and draws j from the whole array each time, which feels more random and is not. That version has n^n execution paths, and n^n is never a multiple of n! for n above 2, so some permutations must be reachable in more ways than others. Measured on the same million runs it scored 29,913 — a thousand times the threshold, with the commonest ordering at 58,698 and the rarest at 31,233. It is the more dangerous of the two errors precisely because it looks like the textbook version and passes every eyeball test.

Modulo bias: the second way to bend a shuffle

Fisher-Yates needs a uniform integer in a range, and the obvious way to get one from a random 32-bit value is to take it modulo the range size. That is uniform only when the range divides 2^32 exactly. It usually does not: 2^32 mod 52 is 48, so 48 of the 52 outcomes receive one more preimage than the other four. At 32 bits the resulting excess is about a millionth of a percent and nobody will ever see it. Shrink the source to a single byte and the same arithmetic becomes brutal: 256 mod 52 is again 48, but now 48 outcomes get 5 preimages and 4 get only 4 — a 25% excess, visible in a few thousand draws.

The fix is rejection sampling and it costs almost nothing. Compute the largest multiple of the range that fits in your source — for a 32-bit draw and a range of 52 that is 2^32 minus 48 — draw again whenever the value lands above it, and take the modulo only of accepted values. The rejection region is 48 values out of 4,294,967,296, so the expected number of extra draws is about one in ninety million. You pay a comparison per call and buy exact uniformity, which is the best trade in this entire article.

Math.random is not a card shuffler

Even a perfect Fisher-Yates is limited by the generator underneath it. A 52-card deck has 52! orderings, which is 8.07 × 10^67, or about 2^225.6. The generator behind Math.random in V8 carries 128 bits of internal state, so it can reach at most 2^128 ≈ 3.4 × 10^38 deck orders — a fraction of 4.2 × 10^-30 of the total. The overwhelming majority of shuffles of a standard deck are simply not producible, no matter how many times you call it. That is a hard mathematical ceiling, not an implementation flaw, and it applies to every pseudorandom generator with less state than the space it is asked to cover.

Two further properties matter in practice. Math.random is not seedable and not reproducible: the specification gives no way to fix a starting point, and it explicitly requires distinct realms to produce distinct sequences, so a bug you saw once cannot be replayed. And it is not unpredictable in the cryptographic sense — an observer who sees enough outputs can reconstruct the state and predict the rest. If anyone could gain by guessing your shuffle — a raffle, a draw with a prize, a security token, anything shuffled before an audience — use crypto.getRandomValues instead, with rejection sampling on top. If the shuffle is a seating plan or a practice quiz, Math.random with a correct Fisher-Yates is entirely fine.

Ratio
A million shuffles of the four items A, B, C, D by three methods, counted permutation by permutation (Node 26.3.0, V8 14.6). Under a fair shuffle each of the 24 orderings should appear about 41,667 times.
MethodRarest orderingCommonest orderingRatioChi-square, 23 dfVerdict at 5% (critical value 27.32)
sort(() => Math.random() - 0.5)30,998 (DBCA)62,810 (BADC)2.026125,397.2Biased beyond any doubt
Fisher-Yates (descending loop, inclusive index)41,434 (ABDC)41,879 (BDAC)1.01110.3Indistinguishable from uniform
Fisher-Yates with the off-by-one (index drawn from the whole array)31,233 (DBCA)58,698 (BADC)1.87929,913.6Biased, and it looks correct
What a fair shuffle would giveabout 41,667about 41,6671.000about 23The reference line
Randomize LinesShuffle text lines with a choice of algorithm, cleanup options and an optional reproducible seed.Try the tool

Frequently asked questions

Is sorting with a random comparator always biased, or only in some browsers?
Always biased, but biased differently everywhere. The standard requires a consistent comparator and declares the result implementation-defined when it does not get one, so each engine — and each version of each engine — produces its own lumpy distribution. On Node 26.3.0 the four-item case collapses to just two probabilities, 1/16 for eight orderings and 1/32 for the other sixteen, which a four-million-run test confirms with a chi-square of 19.7 on 23 degrees of freedom. Another engine will not give you those exact numbers; it will give you different wrong numbers. That is worse than a known, fixed bias, because there is nothing stable to test against and a runtime upgrade can change the statistics of your draw without changing a line of your code.
How do I write Fisher-Yates so that it is actually correct?
Start at the last index and walk down to index 1. At each position i, draw j uniformly from 0 to i inclusive, then swap the elements at i and j. Three things have to be right at once: the loop descends, the draw includes i itself, and the range shrinks by one every iteration. Get all three and the number of execution paths is exactly n factorial, one per permutation, which is what makes the output uniform for every array length rather than for the lengths you happened to test. The variant that walks upwards and draws j from the whole array each time is the classic error — it has n to the power n paths, which is never a multiple of n factorial above n = 2, and it measured a chi-square of 29,913 on the four-item test where a fair shuffle scores about 23.
Do I need crypto.getRandomValues, or is Math.random good enough?
The test is whether anyone could gain by predicting the outcome. Shuffling quiz questions, seating a classroom, randomising the order of practice problems: Math.random inside a correct Fisher-Yates is fine, and the difference will never show. Drawing a prize, picking an audit sample, generating anything that behaves like a token: use crypto.getRandomValues, because Math.random is a pseudorandom generator whose internal state can be reconstructed from a modest run of outputs, after which every future value is predictable. There is a second, quieter reason. A 52-card deck has about 2^225.6 orderings and V8's generator carries 128 bits of state, so it can reach at most one deck order in 10^30 of them. That ceiling is inherent to the state size, not to the quality of the algorithm.
What exactly is modulo bias, and when does it matter?
It is what happens when you squeeze a range that does not divide your source. Take a random 32-bit value and reduce it modulo 52 and you get a number from 0 to 51, but 2^32 divided by 52 leaves a remainder of 48, so 48 of those outcomes have one more source value mapping onto them than the remaining four. At 32 bits the resulting excess is around a millionth of a percent — genuinely negligible. The same arithmetic on an 8-bit source is a different animal: 256 modulo 52 is also 48, but now the favoured outcomes get 5 source values and the others only 4, a 25% excess that a few thousand draws will expose. The fix is rejection sampling: reject any draw at or above the largest multiple of the range that fits your source, which for 32 bits and a range of 52 rejects 48 values out of 4.29 billion, about one draw in ninety million.
How would I test my own shuffle without a statistics background?
Shrink the problem until you can count everything. Take an array of four items, shuffle it a million times, and keep a tally of how often each of the twenty-four orderings appears — a dictionary keyed on the joined string is enough. Then look at two numbers: the largest count divided by the smallest, and the count of the original unshuffled order. A fair shuffle gives a ratio near 1.01 at that sample size and leaves the original order at roughly 1 in 24. In the measurements here, Fisher-Yates gave 1.011 and the one-line sort gave 2.026, with the untouched order appearing 50% more often than it should. You do not need the chi-square to see that gap; the chi-square only tells you how impossible it is, and 125,397 against a threshold of 27.32 is about as impossible as measurements get.

Articles you may find interesting

All guides
GuideSplitting People Into Fair Groups: Random and Fair Are Not the Same Requirement23 people do not divide into fours, and a uniformly random split can hand one group every strong player. Here is the remainder arithmetic, the measured cost of pure randomness, and the stratified fix.GuideTournament Brackets: Byes, Seeds, and Why the Numbers Must Be Powers of TwoA knockout bracket halves its field every round, so it only closes on a power of two. The bye count, the round count, the seed order and the total number of matches all follow from that one fact — and each is a one-line derivation.How-toHow to Pick a Name at Random — Without Anyone Doubting the ResultA fair draw needs more than a random number: equal probability, no reuse of a biased method, and a result someone else can check. Here is how to run one.ExplainerPoker Hand Probabilities, Derived Rather Than MemorisedEvery five-card poker probability is a counting argument over 2,598,960 hands, and each one fits on a single line. Here are all nine, with the check that proves them: the counts must sum to exactly C(52,5).ExplainerTest Card Numbers: What the Luhn Algorithm Is Actually For, and What It Cannot Tell YouLuhn is a checksum for catching typos, patented in 1960, and that is its entire job. A number that passes it tells you nothing about any account. For testing a payment integration you need your processor's published numbers, not a generated one.ExplainerDate Arithmetic Is Harder Than It Looks"One month later" has no single correct answer, and every date library has had to pick one. Month addition is neither associative nor invertible, a day is not always 24 hours, and age is not days divided by 365.25.

Related tools

Sources

Spotted a mistake in this article?