Home Tutorials Standard Library

Standard Library

Python's random Module: Pseudorandom Numbers, Seeding, and Sampling

Pyford Notes July 6, 2026 6 min read
Key points
  • random.random() and friends are pseudorandom — deterministic given a seed, which is exactly what makes debugging possible.
  • choice() picks one item, sample() picks several without repeats, and choices() picks several with repeats and optional weights.
  • random.seed() makes a whole run reproducible, which is invaluable for tests and for reproducing a bug report.
  • Never use the random module for passwords, tokens, or anything security-sensitive — use secrets instead.

random(), randint(), and uniform()

random.random() returns a float in the half-open range [0.0, 1.0). Everything else in the module builds on that one generator:

import random

print(random.random())        # e.g. 0.7139...
print(random.randint(1, 6))   # inclusive both ends: 1 to 6
print(random.uniform(1, 6))   # a float between 1 and 6

The inclusive-both-ends behaviour of randint(a, b) catches people out coming from languages where the upper bound is exclusive by convention — random.randint(1, 6) really can return 6, unlike a slice or a range() call. If you specifically need an exclusive upper bound, random.randrange(1, 6) behaves like range() instead.

choice(), choices(), and sample()

Picking from an existing sequence is more common in practice than generating raw numbers. Three functions cover distinct cases:

deck = ["hearts", "spades", "clubs", "diamonds"]

random.choice(deck)                     # one item, e.g. 'clubs'
random.sample(deck, k=2)                # two distinct items, no repeats
random.choices(deck, k=5)               # five items, repeats allowed
random.choices(deck, weights=[1,1,1,5], k=5)  # diamonds far more likely

sample() raises a ValueError if you ask for more items than the population contains, since sampling without replacement can't produce more unique items than exist. choices() has no such limit because repeats are allowed, and it accepts a weights or cum_weights argument to bias the outcome — handy for anything from loot tables to weighted A/B test assignment.

Shuffling a sequence in place

random.shuffle() reorders a mutable sequence in place and returns None, which is a common source of bugs when someone writes deck = random.shuffle(deck) and ends up with None instead of a shuffled list:

cards = list(range(1, 53))
random.shuffle(cards)
print(cards[:5])   # first five cards after shuffling

Because it mutates in place, shuffle() only works on sequences that support item assignment — a list works, a tuple does not. If you need to keep the original order intact somewhere, shuffle a copy: random.shuffle(cards[:]) or build a new list with random.sample(cards, k=len(cards)), which shuffles by sampling everything without repeats.

Seeding for reproducible runs

Every pseudorandom generator starts from an internal state, and random.seed() sets that state explicitly:

random.seed(42)
print([random.randint(1, 100) for _ in range(5)])
# same five numbers every time this runs with seed 42

This matters for two practical reasons. In tests, a fixed seed makes an otherwise-random function's output deterministic, so assertions can check an exact result instead of just a range. When reproducing a bug, sharing the seed alongside the code lets a colleague see exactly the same "random" sequence you saw. For simulations that need statistically independent streams, create a dedicated generator with random.Random(seed) rather than reseeding the shared module-level instance, which avoids one part of a program accidentally resetting another part's sequence. The same discipline around explicit, deterministic setup shows up in unit testing, where reproducibility is the whole point.

Why random isn't for security: secrets

The random module's generator is a Mersenne Twister — fast and statistically good for simulations, but not cryptographically secure. Given enough consecutive outputs, its internal state can be reconstructed, which means anything generated with it can, in principle, be predicted. For passwords, API tokens, password-reset links, or session identifiers, use the secrets module instead:

import secrets

token = secrets.token_urlsafe(32)
password = secrets.choice("abcdefghijklmnopqrstuvwxyz0123456789")

secrets draws from the operating system's cryptographically secure random source, at the cost of being somewhat slower and not seedable — which is the correct trade-off for anything where predictability would be a security hole. The random module documentation states this distinction explicitly in its warning banner, and it's worth taking seriously rather than reaching for the more familiar module out of habit.

A practical rule that avoids having to remember the distinction case by case: if the value you're generating will ever be compared against something an attacker controls — a password, a reset token embedded in a URL, an API key — use secrets. If it's for a simulation, a game, a sampling routine, or test data where predictability doesn't matter and reproducibility might actually be desirable, random is the right and faster choice. Mixing the two up in the wrong direction, using random for a security token, is a real vulnerability class that has shown up in production systems more than once.