I first needed a genuine “coin flip” in code while building a simple A/B testing harness — half my simulated users needed to see variant A, half variant B, with no bias either way. It sounds trivial, and mostly it is, but getting it fully correct (unbiased, reproducible when needed, and fast at scale) taught me more about Python’s random module than I expected. Here’s everything I’ve learned about making random binary decisions properly.
The Simplest Approach
Python’s built-in random module gives me several equally valid ways to flip a coin.
import random
decision = random.choice([True, False])
print(decision)
import random
decision = random.random() < 0.5
print(decision)
import random
decision = random.getrandbits(1) # returns 0 or 1
print(bool(decision))
All three are correct for a fair, unbiased 50/50 decision, but they differ slightly in performance and intent, which I’ll get into.
How Python’s random Module Generates Randomness
Under the hood, Python’s random module uses the Mersenne Twister algorithm (specifically the MT19937 variant) as its default pseudo-random number generator. It’s a well-studied, fast algorithm with an extremely long period (2^19937 − 1 before the sequence repeats), which makes it excellent for simulations, games, and general-purpose randomness — but it is not cryptographically secure, a distinction I’ll come back to.
The generator is seeded — either automatically from the operating system’s entropy sources at import time, or manually via random.seed() for reproducibility.
import random
random.seed(42)
print([random.choice([True, False]) for _ in range(5)])
random.seed(42)
print([random.choice([True, False]) for _ in range(5)])
# identical output both times, because the seed is the same
This reproducibility is genuinely useful for testing and debugging — I can fix a seed, run my code, and get the exact same “random” decisions every time, which makes bugs reproducible instead of intermittent.
Comparing the Three Methods
random.choice([True, False])
This is the most readable option, in my opinion — it clearly communicates “pick one of these two things” to anyone reading the code. Internally, random.choice() on a sequence works by generating a random index within the sequence’s length and returning the element at that index.
import random
for _ in range(5):
print(random.choice([True, False]))
random.random() < 0.5
random.random() returns a float uniformly distributed in [0.0, 1.0). Comparing it to 0.5 gives a boolean with (very close to) equal probability on each side, since the underlying distribution is uniform.
import random
for _ in range(5):
print(random.random() < 0.5)
This approach generalizes naturally to biased decisions — if I want a 70% chance of True instead of a fair coin flip, I just change the threshold:
decision = random.random() < 0.7 # 70% chance of True
random.getrandbits(1)
This directly asks the generator for a single random bit, which is the most low-level and (marginally) fastest option, since it avoids the overhead of list indexing or floating-point comparison.
import random
for _ in range(5):
print(bool(random.getrandbits(1)))
random.getrandbits(k) is actually the foundational primitive that many other random module functions are built on top of internally — generating k random bits and returning them as an integer.
Benchmarking the Options
If performance matters — say, I’m making millions of binary decisions in a simulation — the differences are measurable, even if small per call:
import random
import timeit
def method_choice():
return random.choice([True, False])
def method_random():
return random.random() < 0.5
def method_getrandbits():
return bool(random.getrandbits(1))
print("choice: ", timeit.timeit(method_choice, number=1_000_000))
print("random() < .5:", timeit.timeit(method_random, number=1_000_000))
print("getrandbits: ", timeit.timeit(method_getrandbits, number=1_000_000))
In my own testing, random.getrandbits(1) tends to be the fastest since it skips list construction and indexing overhead, random.random() < 0.5 sits in the middle, and random.choice() tends to be slightly slower due to the extra generality of handling arbitrary sequences. For most everyday scripts this difference is completely irrelevant, but it matters in tight simulation loops running millions of iterations.
Making a Biased (Weighted) Binary Decision
Real-world binary decisions are often not perfectly 50/50. For a weighted coin flip:
import random
def weighted_decision(probability_true):
return random.random() < probability_true
print(weighted_decision(0.9)) # 90% chance of True
print(weighted_decision(0.1)) # 10% chance of True
For more complex weighted choices among more than two options, random.choices() (plural, note the ‘s’) supports explicit weights:
import random
result = random.choices([True, False], weights=[0.8, 0.2], k=1)[0]
print(result) # heavily biased toward True
random.choices() uses cumulative weight sums internally and a random float to determine which “bucket” the outcome falls into — this generalizes cleanly beyond just two outcomes to any number of weighted categories.
When You Need Cryptographic-Grade Randomness
Here’s the crucial caveat: the default random module is not suitable for security-sensitive decisions. Because Mersenne Twister is deterministic given its internal state, and that state can in some circumstances be reconstructed from enough observed outputs, it must never be used for things like generating security tokens, session IDs, or any decision where an adversary predicting the outcome would matter.
For those cases, Python provides the secrets module, built on the operating system’s cryptographically secure random source:
import secrets
decision = secrets.choice([True, False])
print(decision)
# or working with raw random bits
decision = secrets.randbits(1)
print(bool(decision))
secrets is slower than random (since it draws from the OS’s CSPRNG, which involves more overhead than a pure userspace algorithm like Mersenne Twister), but that cost is the price of unpredictability guarantees that actually matter for security use cases — like deciding which of two encryption keys to use, or randomly selecting a security challenge.
Reproducibility vs. True Randomness: Choosing the Right Tool
| Use case | Recommended approach |
|---|---|
| Games, simulations, general scripts | random module |
| A/B testing where reproducibility for debugging matters | random module with an explicit seed |
| Security tokens, cryptographic decisions | secrets module |
| Statistical sampling and Monte Carlo simulations | random (or numpy.random for vectorized performance at scale) |
Vectorized Binary Decisions with NumPy
When I need thousands or millions of independent binary decisions at once — for a Monte Carlo simulation, say — looping in pure Python is slow. NumPy’s vectorized random generation is dramatically faster because it generates a whole array of results in a single optimized C-level call rather than looping in the Python interpreter.
import numpy as np
rng = np.random.default_rng(seed=42)
decisions = rng.random(1_000_000) < 0.5 # array of a million booleans, vectorized
print(decisions[:10])
print(decisions.sum()) # roughly 500,000 True values
numpy.random.default_rng() uses the PCG64 algorithm by default (a more modern generator than Mersenne Twister, with better statistical properties and faster generation for large arrays), and operating on the whole array at once avoids the per-iteration overhead of a Python-level loop entirely.
Real-World Applications
- A/B testing frameworks, randomly assigning users to test groups.
- Game development, for coin flips, random events, or procedural generation decisions.
- Monte Carlo simulations, where large numbers of independent binary trials model probabilistic systems (like simulating disease spread, or estimating probabilities through repeated sampling).
- Load balancing and traffic splitting, randomly routing requests between two backend services for canary deployments.
- Randomized algorithms, like randomized quicksort’s pivot selection or randomized tie-breaking in decision logic.
Common Mistakes
Using random for security-sensitive decisions. This is the single most important mistake to avoid — never use random.choice() or similar for anything where predictability would be a security risk. Use secrets instead.
Forgetting to seed for reproducible tests, leading to flaky test failures that only occur “sometimes” because the underlying random decision differs across runs.
Assuming random.random() < 0.5 and random.choice([True, False]) differ in fairness. They don’t — both are equally unbiased for a fair coin flip; the difference is purely about code clarity, generalizability to weighted decisions, and marginal performance.
Reseeding inside a loop, which can accidentally produce identical “random” values repeatedly if the seed is derived from something that doesn’t actually change between iterations (like a coarse-grained timestamp).
Debugging Tips
- If a “random” decision seems suspiciously non-random (always the same result), check whether
random.seed()is being called somewhere unexpectedly, resetting the generator’s state. - For statistical validation, run the decision many times (tens of thousands) and confirm the observed ratio of
TruetoFalseconverges close to your expected probability — small sample sizes can look “biased” purely by chance even when the underlying probability is correct. - When debugging simulations with reproducibility issues across machines, remember that while the algorithm (Mersenne Twister) is consistent across platforms given the same seed, differences in how you derive that seed (like using system time with different precision) can cause apparent inconsistency.
Performance Considerations
- For single, occasional binary decisions, any of
random.choice(),random.random() < 0.5, orrandom.getrandbits(1)are fast enough that the difference is irrelevant. - For large-scale simulations needing millions of decisions, prefer
numpy‘s vectorized random generation over a Python-level loop — the difference in performance can be an order of magnitude or more. - Never use
secretsfor high-frequency, non-security-sensitive decisions purely out of an abundance of caution — it’s meaningfully slower and the extra unpredictability guarantees aren’t needed outside genuine security contexts.
FAQs
Is random.random() < 0.5 truly a fair 50/50 split? Yes, assuming the underlying generator is unbiased (which Mersenne Twister is, for non-adversarial use), the float returned is uniformly distributed in [0, 1), making the comparison to 0.5 an unbiased binary decision.
Should I use random or secrets for a simple coin-flip game? random is entirely appropriate for games, simulations, and anything non-security-related — secrets is overkill and unnecessarily slower for these cases.
How do I make a weighted (biased) coin flip? Use random.random() < probability with your desired probability, or random.choices() with explicit weights for more than two outcomes.
Does seeding random affect secrets or numpy.random too? No — random.seed() only affects the state of Python’s built-in random module’s generator. secrets draws from the OS’s CSPRNG regardless of any seeding, and numpy.random maintains its own separate generator state.
Summary
A random binary decision in Python can be made several equally valid ways — random.choice([True, False]), random.random() < 0.5, or random.getrandbits(1) — each unbiased but differing slightly in readability, generalizability to weighted decisions, and raw performance. The critical distinction to internalize is between Python’s default random module (fast, reproducible via seeding, but not cryptographically secure) and the secrets module (slower, but suitable for security-sensitive randomness). For large-scale simulations, vectorized generation with NumPy dramatically outperforms looping in pure Python. Choosing the right tool depends entirely on whether your binary decision needs speed, reproducibility, or genuine unpredictability.
References
- Python official documentation:
randommodule - Python official documentation:
secretsmodule - NumPy official documentation: Random sampling (
numpy.random) - PEP 506 (“Adding A Secrets Module To The Standard Library”) on docs.python.org
