PYTHON / NUMPY
Random numbers and reproducible seeds
Create seeded NumPy Generators with default_rng, reason about how seed and draw order fix results, and spawn independent streams for parallel work.
What you will learn
- Create one seeded Generator with np.random.default_rng(seed) and pass it around
- Explain why a second draw differs but a re-seeded generator repeats exactly
- Use np.random.SeedSequence(seed).spawn(n) for independent reproducible streams
- Snapshot and rewind a stream with rng.bit_generator.state
Understanding Random numbers and reproducible seeds
NumPy's random numbers are not random at all: a bit generator (PCG64 by default) holds a small integer state, and each draw applies a fixed arithmetic transform to produce a value and advance that state. The seed is just the starting state, so a seed picks out one specific, endlessly long sequence of numbers. The Generator object you get from np.random.default_rng(seed) is a cursor walking along that sequence, which is why two generators built from the same seed hand back identical values while a single generator never repeats itself.
Older code uses np.random.seed(0) followed by np.random.rand, np.random.normal and friends. Those module-level functions all share one hidden global RandomState, so any other part of the program, or a library you imported, can consume values from it and silently shift every draw that follows. A Generator you create and pass explicitly has its own private state, so nothing outside your function can move your cursor, and np.random.seed has no effect on it whatsoever. Preferring an explicit rng also makes randomness visible in your function signatures instead of hiding it in global state.
Because the cursor position matters, the seed alone does not pin down a result: the number and order of draws matter too. Change rng.random(5) to rng.random(6), or add one debug draw, and everything downstream shifts. For many parallel workers, do not invent seeds by hand; ask np.random.SeedSequence(master).spawn(n) for child seeds, which are mixed so the resulting streams are effectively independent yet fully reproducible from the single master seed.
import numpy as np
a = np.random.default_rng(12345).random(4)
b = np.random.default_rng(12345).random(4)
c = np.random.default_rng(999).random(4)
print("same seed -> same values:", np.array_equal(a, b))
print("different seed: ", np.array_equal(a, c))
rng = np.random.default_rng(12345)
first = rng.random(4)
second = rng.random(4)
print("first draw matches a: ", np.array_equal(first, a))
print("second draw matches a: ", np.array_equal(second, a))A seed selects one deterministic sequence and the Generator is a cursor into it, so results depend on both the seed and how many values you have already drawn.
Worked examples
Global seed versus a private Generator
Shows how one stray draw from the global np.random stream changes every later value, while a Generator is immune to np.random.seed.
import numpy as np
np.random.seed(0)
run1 = np.random.normal(size=3)
np.random.seed(0)
np.random.rand() # one extra draw sneaks in
run2 = np.random.normal(size=3)
print(np.array_equal(run1, run2))
rng = np.random.default_rng(0)
np.random.seed(0) # touches the global stream, not rng
print(np.array_equal(rng.normal(size=3),
np.random.default_rng(0).normal(size=3)))Example explained
Line 1np.random.seed(0) resets a single hidden RandomState shared by every np.random.* function.
Line 2The bare np.random.rand() consumes one value, so the following normal() call starts one step further along and run2 differs from run1.
Line 3rng keeps its own state, so np.random.seed(0) cannot reach it and rng still matches a fresh default_rng(0).
Independent streams for many workers
Turns one master seed into three well-separated child streams that stay reproducible across runs.
import numpy as np
def draw_all(master_seed):
seeds = np.random.SeedSequence(master_seed).spawn(3)
return [np.random.default_rng(s).integers(0, 100, size=3) for s in seeds]
worker_draws = draw_all(2024)
print(np.array_equal(worker_draws[0], worker_draws[1]))
print(all(np.array_equal(x, y) for x, y in zip(worker_draws, draw_all(2024))))
print(len(worker_draws), worker_draws[0].shape)Example explained
Line 1SeedSequence(2024) mixes one human-chosen number into high-quality child seeds.
Line 2Each child seeds its own Generator, so worker 0 and worker 1 walk different parts of the number space and their draws are not equal.
Line 3Calling draw_all(2024) again regenerates the same children in the same order, so the whole job replays exactly.
Saving and rewinding the stream position
Copies the bit generator state so a block of draws can be replayed without recreating the generator.
import numpy as np
rng = np.random.default_rng(7)
rng.random(5) # advance the cursor
snapshot = rng.bit_generator.state # a plain dict describing where we are
x = rng.standard_normal(3)
rng.bit_generator.state = snapshot # rewind to the snapshot
y = rng.standard_normal(3)
print(snapshot["bit_generator"])
print(np.array_equal(x, y))
print(np.array_equal(x, rng.standard_normal(3)))Example explained
Line 1bit_generator.state returns a fresh dict each time it is read, so snapshot is a safe copy of the internal counter.
Line 2Assigning snapshot back puts the cursor where it was, so y reproduces x value for value.
Line 3After y is drawn the cursor has moved on again, so the third draw is new: the stream only repeats when you rewind it.
Important notes
default_rng() with no argument pulls fresh entropy from the OS; if you need to replay a failure later, read and log np.random.default_rng().bit_generator.seed_seq.entropy.
Reproducible is not secure: PCG64 output is fully predictable from its state, so use the secrets module for tokens, and pin your NumPy version if you need bit-identical Generator streams across machines.
Common mistakes
Calling np.random.default_rng(42) inside a loop or inside the function being called repeatedly: every call rewinds to the same start, so all bootstrap samples or all noise vectors come out identical.
Writing np.random.seed(42) and then drawing from default_rng(): the two keep separate states, so the Generator stays unseeded and results change on every run.
Recording only the seed and later inserting a debug draw such as rng.random(): the seed is unchanged but the cursor moved, so the old numbers can no longer be reproduced.
Try it yourself
Change, predict, then run
Write a function noisy_mean(rng, n) that returns rng.normal(size=n).mean(), then call it three times with one shared default_rng(5) and print the results, and again with a freshly created default_rng(5) each call; explain in a comment why one set varies and the other does not.
Open the Python workspaceCheck your understanding
A script starts with np.random.seed(42) and later calls np.random.normal in several places. A colleague adds a single np.random.rand() near the top for debugging, and every downstream number changes. Why?
- All np.random.* functions consume from one shared stream, so the extra draw shifts every later value
- np.random.seed(42) only fixes the first draw; later draws are effectively unseeded
- rand and normal use different bit generators, so mixing them breaks the seed
- The seed expires after the first function call and must be set again
Show answer
seed(42) sets the initial state of a single global RandomState, and each call advances that shared cursor, so inserting one draw moves every later draw one step along the sequence. The results are still perfectly reproducible for the modified script, which rules out the "unseeded" and "expires" options; rand and normal read from the same bit generator, they just consume different amounts of it.