How to Generate Random Numbers in Python (random, NumPy and Seeds)

By Dr. Zubair Khalid, DVM, MS, PhD ·

How to Generate Random Numbers in Python (random, NumPy and Seeds)

You generate a random number in Python with the standard library's random module for a single value or a short sequence, or with NumPy when you need arrays of random numbers or statistical distributions. Both libraries are pseudorandom, meaning the numbers come from a deterministic algorithm, so passing a seed such as 42 makes the same sequence appear again on the next run.

That single distinction, deterministic algorithm versus true physical randomness, explains most of the confusion people hit when they write simulation code, build a train/test split, shuffle a card deck, or try to reproduce a machine learning result. This guide covers the functions you will actually call, how random.randint differs from NumPy's integer functions, what a seed does to the internal state, and the mistakes that quietly break reproducibility.

What "Random" Means Inside a Python Program

A computer cannot flip a coin. It can only run arithmetic. A pseudorandom number generator (PRNG) is an algorithm that keeps an internal state, applies a mathematical transformation to that state, and returns the transformed value as the next "random" number. The output looks unpredictable because the transformation scrambles the state thoroughly, but it is fully determined by two things: the algorithm and the starting state.

Two terms matter here.

The seed is the initial value handed to the generator. It is what turns a fixed algorithm into a stream of numbers that looks different each time.

The state is the generator's entire internal memory at any moment. If you snapshot the state and restore it later, you get the identical continuation of the sequence.

Python's standard random module uses the Mersenne Twister algorithm, specifically MT19937, as its core generator. The CPython documentation describes it as producing 53-bit precision floats with a period of 2**19937 - 1. A period that large means the sequence repeats only after an astronomically long run, so repetition is not a practical concern for simulations or games.

NumPy takes a different path. Its modern API defaults to the PCG64 bit generator, which has a 128-bit period and passes stringent statistical test batteries. The older NumPy functions use a Mersenne Twister variant for backward compatibility.

Neither library produces cryptographic randomness. That point returns later, because using random to build passwords or session tokens is a genuine security bug rather than a style preference.

The Python random Module: One Value at a Time

Import the module once at the top of your file.

import random

random.random() Returns a Float Between 0 and 1

random.random() returns a float in the half-open interval [0.0, 1.0). The value can be exactly 0.0. It can never be exactly 1.0. That half-open behavior is deliberate and useful, because multiplying by a range length gives you a clean bucket:

x = random.random() * 10   # 0.0 up to but never including 10.0

If you need a float across an arbitrary interval, random.uniform(a, b) returns a + (b - a) * random(). The documentation notes that the endpoint b may or may not be included depending on floating point rounding, so do not build logic that depends on hitting an endpoint exactly.

Random Integers: random.randint and random.randrange

This is the part that trips people up most often. random.randint(a, b) returns an integer N with a <= N <= b. Both endpoints are inclusive.

random.randint(1, 6)    # simulates a six-sided die, outcomes 1 through 6
random.randint(0, 9)    # ten possible outcomes: 0, 1, 2, ..., 9

random.randint is implemented as a call to randrange(a, b + 1), and the documentation says so directly. That means random.randint(0, 9) and random.randrange(0, 10) are the same thing.

random.randrange(start, stop, step) follows the same convention as Python's range function. The stop value is excluded.

random.randrange(10)          # 0 through 9
random.randrange(0, 10, 2)    # 0, 2, 4, 6, 8

For raw bit generation, random.getrandbits(k) returns a non-negative integer with k random bits. random.getrandbits(8) gives a value from 0 to 255.

random.choice and the Sequence Functions

random.choice(seq) picks one element uniformly from a non-empty sequence. Pass a list to get a list element, pass a string to get one character, pass a tuple to get one tuple element.

colors = ["red", "green", "blue"]
random.choice(colors)

An empty sequence raises IndexError. Guard against that, because an empty list is a common runtime condition when data loading fails.

Three related functions do more work.

random.choices(population, weights=None, k=1) returns a list of k elements drawn with replacement. Supply weights to bias the draw. This is the function you want for weighted sampling such as loot tables or categorical simulation.

random.choices(["common", "rare", "epic"], weights=[70, 25, 5], k=3)

random.sample(population, k) returns a list of k distinct elements drawn without replacement. If k exceeds the population length, it raises ValueError. Since Python 3.11 the population must be a sequence, so sets are no longer accepted.

random.sample(range(1, 50), 6)   # six distinct lottery-style numbers

random.shuffle(x) shuffles a mutable sequence in place and returns None. This is the single most common source of the NoneType object is not subscriptable error, because people write deck = random.shuffle(deck) and destroy their own list reference. Write it as two lines.

random.shuffle(deck)

Continuous and Named Distributions

Beyond uniform, the module ships gauss(mu, sigma) for a normal distribution, normalvariate(mu, sigma) as a slower but independent implementation, expovariate(lambd) for exponential waiting times, triangular, betavariate, gammavariate and lognormvariate. For most work gauss is fine. The documentation notes that gauss is slightly faster than normalvariate.

Seeding and Reproducibility

random.seed(a=None, version=2) initializes the generator. With no argument, or None, Python pulls entropy from the operating system, which is why your script produces different numbers each run.

Pass an integer to make the sequence reproducible.

random.seed(42)
print(random.random())   # prints the same value on every run, every machine

The a parameter accepts an int, str, bytes or bytearray. With version=2, the default, a str or bytes seed is converted to an integer using all of its bits with SHA-512. That means random.seed("experiment-1") works and gives a stable, repeatable stream.

Two properties of seeding deserve attention.

First, seeding happens once. Calling random.seed(42) inside a loop restarts the stream every iteration, so you get the same value over and over. This is the classic bug in simulation code. Seed at the top of the program or at the top of the function that owns the randomness, never inside the loop that consumes it.

Second, the module-level functions share one global generator. If two threads, two functions, or two imported libraries all call random.random(), they interleave on the same state. The fix is an instance.

rng = random.Random(42)
rng.random()

A random.Random object has its own state and is unaffected by anything else in the process. Use one instance per component that needs its own independent, reproducible stream.

For checkpointing, random.getstate() returns a tuple capturing the current state and random.setstate(state) restores it. This lets you pause a long simulation, save progress, and resume the exact same sequence later.

NumPy Random: Arrays, Speed and Two APIs

NumPy exists to do arithmetic on whole arrays at once. That is exactly what most simulations, bootstraps and machine learning pipelines need. Generating one million floats with random.random in a Python loop takes roughly three orders of magnitude longer than a single NumPy call of the same size.

NumPy currently carries two APIs. Knowing which one you are reading matters, because they disagree on a critical detail.

The Legacy API: np.random.seed and Friends

import numpy as np
np.random.seed(42)
np.random.rand(3)              # array of 3 floats in [0, 1)
np.random.randint(0, 10, 5)    # 5 integers from 0 up to but not including 10
np.random.randn(1000)          # 1000 draws from a standard normal

The legacy functions are module-level and share a global state, mirroring the design of Python's random module. They remain in NumPy for backward compatibility, and the NumPy documentation recommends them only for maintaining old code.

The trap is here: np.random.randint(low, high) treats high as exclusive, while random.randint(a, b) treats b as inclusive. np.random.randint(0, 1) always returns 0. The equivalent of a fair coin flip is np.random.randint(0, 2).

np.random.seed() accepts only None or an integer. Passing a string raises TypeError, unlike Python's random.seed.

The Modern API: np.random.default_rng

rng = np.random.default_rng(42)
rng.random(3)                  # floats in [0, 1)
rng.integers(0, 10, size=5)    # integers, high exclusive by default
rng.integers(1, 6, endpoint=True)   # 1 through 6, inclusive of both ends
rng.normal(0, 1, size=1000)    # mean 0, standard deviation 1
rng.choice(["a", "b", "c"], size=4, p=[0.5, 0.3, 0.2])

default_rng returns a Generator object backed by PCG64. It is an instance, not a global, so it eliminates cross-module state collisions.

rng.integers returns integers in [low, high) by default. Set endpoint=True to include high, which finally gives NumPy a direct equivalent of random.randint. If high is omitted, the range becomes [0, low).

The size argument accepts an integer or a shape tuple, so rng.random((1000, 3)) returns a 1000 by 3 matrix in one call. The dtype argument lets you control the integer width, for example dtype=np.uint8 for byte-range values.

default_rng also accepts an array of integers as a seed, a SeedSequence, or an existing BitGenerator or Generator. It does not accept strings.

Independent Streams with SeedSequence

Parallel workloads need multiple reproducible streams that are not correlated. np.random.SeedSequence handles this without guesswork.

ss = np.random.SeedSequence(12345)
children = ss.spawn(4)
workers = [np.random.default_rng(child) for child in children]

Each child seed produces a statistically independent stream. This is the correct replacement for the old pattern of seeding with worker_id or base_seed + i, which can produce correlated streams in some generators.

Side-by-Side Comparison

Taskrandom moduleNumPy legacyNumPy Generator
Float in [0, 1)random.random()np.random.rand()rng.random()
Integer, both ends inclusiverandom.randint(a, b)not available directlyrng.integers(a, b, endpoint=True)
Integer, high exclusiverandom.randrange(a, b)np.random.randint(a, b)rng.integers(a, b)
Pick one elementrandom.choice(seq)np.random.choice(arr)rng.choice(arr)
Pick k without replacementrandom.sample(seq, k)np.random.choice(arr, k, replace=False)rng.choice(arr, k, replace=False)
Weighted pickrandom.choices(pop, weights, k)np.random.choice(arr, k, p=...)rng.choice(arr, k, p=...)
Shuffle a sequencerandom.shuffle(lst)np.random.shuffle(arr)rng.shuffle(arr)
Normal drawsrandom.gauss(mu, sigma)np.random.randn() or np.random.normal()rng.normal(loc, scale, size)
Set a seedrandom.seed(42)np.random.seed(42)np.random.default_rng(42)
Recommended for new codeyesnoyes

Reproducibility in Practice

Reproducibility means that the same seed and the same software produce the same numbers. It does not mean the numbers are random in a physical sense. It also does not mean every library call will match forever across software versions.

NumPy addresses this explicitly. Its published policy on versioned reproducibility, often cited as NEP 19, states that stream compatibility is guaranteed for the newer Generator methods given a fixed bit generator and the same version. The legacy module-level functions carry no such guarantee. The practical consequence is that a seed plus a NumPy version number plus a saved generator type is a complete reproduction recipe, while a bare np.random.seed(42) is not.

A second problem is library sprawl. Seeding NumPy does nothing for Python's random module, and seeding both does nothing for scikit-learn, TensorFlow, PyTorch or PySpark, which each have their own generators. A reproducible pipeline typically seeds every one of them and also sets PYTHONHASHSEED in the environment when set or dict ordering could affect results.

A third problem is thread count. Libraries that parallelize with reduction operations can produce different floating point sums depending on the number of threads, even with a fixed seed. For strict bit-level reproduction, pin the thread count as well.

Store the seeds you use. A seed is an experimental parameter. Log it next to the results the same way you log a parameter value, and if you tune a model, keep the evaluation seed fixed so that comparisons are fair.

Randomness for Security Is a Different Problem

The random module's own documentation states plainly that its generators should not be used for security or cryptographic purposes. Mersenne Twister outputs are predictable once enough values are observed, which makes the stream recoverable by an attacker.

Python provides the secrets module for tokens, passwords, API keys and nonces.

import secrets
secrets.randbelow(100)      # integer from 0 to 99
secrets.choice("abc123")    # one character
secrets.token_hex(16)       # 32-character hex string, 128 bits
secrets.token_urlsafe(32)   # URL-safe string for reset links

The secrets module draws from os.urandom, which the operating system fills from a cryptographically secure entropy source. There is no seed parameter. You cannot reproduce a token, and that is the point.

random.SystemRandom is the older route to the same entropy. random.SystemRandom().randint(1, 6) gives an unreproducible die roll backed by os.urandom. For new code, secrets is the clearer choice because the name states the intent.

Practical Recipes

  1. Simulate a die roll. random.randint(1, 6) gives one roll. For twenty rolls use [random.randint(1, 6) for _ in range(20)].
  2. Draw a lottery ticket. random.sample(range(1, 70), 5) gives five distinct numbers. Do not use choices with k=5, because it allows duplicates.
  3. Shuffle a deck or a dataset. random.shuffle(deck) reorders a list in place. For NumPy arrays use rng.permutation(len(arr)) and index with it.
  4. Bootstrap resample. rng.choice(data, size=len(data), replace=True) produces one bootstrap sample. Repeat inside a loop for a confidence interval.
  5. Weighted category draw. random.choices(labels, weights=probs, k=1)[0] returns one label. Weights do not need to sum to 1, since the function normalizes them.
  6. Train/test split by hand. Shuffle an index list with random.shuffle(indices) and slice the first 80 percent. Seed the shuffle so the split is identical across experiments.
  7. Synthetic normal data. rng.normal(loc=170, scale=8, size=5000) produces 5,000 draws for a height-like distribution with mean 170 and standard deviation 8.
  8. Quick uniformity check. Generate 10,000 values with rng.random(10000) and plot a histogram. It should be flat. A mean near 0.5 and a standard deviation near 0.2887 confirms the expected first and second moments for a uniform distribution on [0, 1).

Common Mistakes and Limitations

Confusing inclusive and exclusive upper bounds. random.randint(0, 1) returns 0 or 1. np.random.randint(0, 1) always returns 0. rng.integers(0, 1) also always returns 0. Read the upper bound rule every time you switch libraries.

Seeding inside a loop. for i in range(100): random.seed(42); x = random.random() produces one hundred identical values. Seed once, before the loop.

Reassigning the result of shuffle. random.shuffle() returns None. The list is modified in place. The same is true of np.random.shuffle for a one-dimensional array, while rng.shuffle also works in place. Use np.random.permutation when you want a new array back.

Assuming the global generator is safe across threads or modules. The module-level functions share state. Use random.Random(seed) or default_rng(seed) instances when that matters.

Passing a string seed to NumPy. np.random.seed("abc") raises TypeError. np.random.default_rng("abc") also raises TypeError. Convert the string to an integer yourself, for example with int.from_bytes(hashlib.sha256(s.encode()).digest()[:8], "big").

Using sample when duplicates are allowed. random.sample refuses to draw more elements than the population contains. If you need repeated draws from a small pool, random.choices is the correct function.

Modulo bias. random.randrange(n) is built to avoid it, but random.getrandbits(32) % n is not, because the number of possible outputs is not a multiple of n. Use randrange, randint or secrets.randbelow instead of writing the modulo yourself.

Treating a seed as a substitute for randomness. A seed makes a sequence repeat. If you clear it every run to get "more randomness" you lose reproducibility without gaining security. Pick one: reproducible with a recorded seed, or unpredictable with secrets.

Expecting NumPy arrays from the random module. random.choices(pop, k=1000) returns a Python list, not an array. It will be slow to process with NumPy operations. Convert with np.array(...) or generate directly with rng.choice.

Overflowing small integer dtypes. rng.integers(0, 300, dtype=np.uint8) raises an error because 300 exceeds the 0 to 255 range of that dtype. Match the dtype to the value range.

Reproducibility across machines and versions. A fixed seed does not guarantee bit-identical results across NumPy versions, floating point libraries, or thread counts. Treat version pinning as part of the reproducibility story.

Statistical quality is not the same as unpredictability. MT19937 and PCG64 pass strong statistical tests and are fine for simulation. Neither resists a determined adversary. Different requirements, different tools.

Frequently Asked Questions

How do I generate a random number in Python?

Use random.randint(a, b) for an integer between two inclusive bounds, random.random() for a float in [0, 1), or np.random.default_rng().random() for an array. Choose based on whether you need one value or an array.

What is the difference between random.randint and random.randrange?

random.randint(a, b) includes both endpoints. random.randrange(a, b) includes a and excludes b, matching the behavior of Python's range. randint(a, b) is internally randrange(a, b + 1).

Why does np.random.randint(0, 1) always return 0?

NumPy's integer functions treat the upper bound as exclusive, so the valid values are only those strictly below 1. Use np.random.randint(0, 2) or rng.integers(0, 1, endpoint=True) for a coin flip.

How does random.seed work?

random.seed(value) sets the initial state of the generator, so the same seed produces the same sequence on every run. With no argument it draws fresh entropy from the operating system, which is why runs differ by default.

Is Python's random module secure enough for passwords?

No. The CPython documentation states that the module is not suitable for security or cryptographic work. Use the secrets module, which reads from the operating system's cryptographic entropy source and cannot be seeded.

Can I get the same random numbers in NumPy and in Python's random module?

No. The two libraries use different algorithms and consume seeds differently, so the same integer seed produces different sequences. Seed each library independently and record both seeds.

What is the fastest way to generate a million random numbers?

Use NumPy. np.random.default_rng(42).random(1_000_000) returns a million floats in a single call, far faster than a Python loop calling random.random() a million times.

Why does random.shuffle return None?

random.shuffle reorders the list in place and returns nothing. Assigning its result overwrites your list with None. Call it as a statement and keep the original variable.

References

  1. Python documentation: random, generate pseudo-random numbers
  2. Python documentation: secrets, generate secure random numbers
  3. NumPy documentation: Random Generator
  4. NIST SP 800-90A Rev. 1: Recommendation for random number generation using deterministic random bit generators
  5. Matsumoto M, Nishimura T (1998). Mersenne Twister: a 623-dimensionally equidistributed uniform pseudo-random number generator. ACM Trans Model Comput Simul 8:3-30.

Related Articles