Random Numbers

The modern Generator API, why np.random.seed is legacy, and how to make results reproducible.

Overview

Two APIs, one of them legacy

Random number generation in NumPy has a clear best practice, but the legacy interface still appears in most tutorials and examples. Knowing both is necessary for reading existing code. Using the new one is better for writing new code.

The legacy interface uses module-level functions: np.random.rand, np.random.randint, np.random.normal, np.random.seed. They all share a single hidden global generator. Setting the seed in one place affects every call everywhere. That makes reproducibility fragile. Two libraries that both call np.random.seed can interfere with each other.

The new interface creates explicit generator objects. rng = np.random.default_rng(42) gives you a generator seeded with 42. rng.random, rng.integers, rng.normal are methods on that object. Each generator carries its own state. Two generators with different seeds are independent. That makes testing predictable and parallel code safe.

The method names differ between the two interfaces. The legacy randint includes the low endpoint and excludes the high. The new integers includes both by default. That catches people who switch between the two without reading the signature.

Beyond the interface question, random number generation in NumPy is fast because it generates arrays directly. Calling rng.normal(size=1000000) gives you a million samples in one call. Calling it in a loop, once per iteration, is a hundred times slower. The size parameter is not a convenience. It is the intended way to generate batches.

Distributions beyond uniform and normal are available: exponential, poisson, binomial, beta, gamma, and many others. Each takes the distribution parameters and a size argument. Sampling from a discrete set uses rng.choice, which handles replacement, weights, and multi-dimensional output.

This module covers the new generator API, the most common distributions, shuffling and permutation, and the reasons to avoid the legacy interface in new code. The examples use explicit generators throughout.

You'll see both in the wild.

np.random.rand, np.random.randint, np.random.seed are the legacy interface. They all operate on a single hidden global gen.

np.random.default_rng() returns a gen object with its own state. each method lives on that object.

The new one is preferred, and the reason isn't cosmetic. A global gen means any library you import can consume from the same stream. Your seeded results change when an unrelated dependency starts drawing numbers. It also can't be used safely from more than one thread, and it makes tests interfere with each other in ways that are hard to trace.

An clear gen is passed where it's needed. Nothing else can touch it. Create it once, near the top, and thread it through.

The legacy functions aren't deprecated and existing code doesn't need rewriting. New code should use default_rng.

Worth knowing

np.random.default_rng(seed) is the current API. The legacy np.random.seed mutates one hidden global generator.
The same seed gives the same sequence, and a generator carries state — consecutive draws differ.
Every method takes size, so you draw whole arrays at once rather than looping.
integers(low, high) is half-open like range: high is excluded.
choice handles replacement and weights; shuffle is in place and permutation returns a copy.
Split data by permuting an index array once and slicing it — sampling twice can put the same row in both halves.

Random Numbers

The modern gen API, and how to make results reproducible.

default_rng is the current API

Create a generator, seed it once, and pass it around.

example_01.pyNumPy
Output

The same seed gives the same numbers

That is what reproducibility means, and it is per generator.

example_02.pyNumPy
Output

Shapes, not loops

Every method takes a size, so you draw the whole array at once.

example_03.pyNumPy
Output

Sampling, with and without replacement

choice covers both, plus weighted draws.

example_04.pyNumPy
Output

Shuffling and permutations

shuffle modifies in place; permutation returns a new array.

example_05.pyNumPy
Output

A train/test split, correctly

Permute indices once, then slice - never sample twice.

example_06.pyNumPy
Output

Seeding

default_rng(42) gives a gen whose sequence is fully found. Two gens created with the same seed give same output.

The gen is stateful: each call advances it. In a row draws from the same gen differ. That is the point. You seed once, not before each call.

Seeding inside a loop is a common bug. It makes the same "random" value each loop. The symptom is data that looks oddly uniform.

For truly unpredictable values, call default_rng() with no arg. It seeds from the operating system.

Shapes

each method takes size, and it accepts a tuple.

rng.random((3, 4)) gives a (3, 4) array in one call. There's never a reason to loop.

rng.ints(low, high) is half-open, like range and like slicing: high is excluded. The legacy randint behaved the same way. random_integers, now removed, didn't. Which is why the memory is unreliable and worth checking.

The distributions

random for uniform [0, 1). ints for uniform ints. normal(loc, scale) for Gaussian. uniform(low, high) for a uniform range other than the unit interval.

Beyond those there are dozens. poisson, steep, binomial, multivariate_normal — all following the same size rule.

choice

rng.choice(pool, size=n) samples with swap by default.

replace=False samples without, and raises if size exceeds the pool. That is the right action. Silently returning copies when you asked for distinct items would be worse.

p=weights gives a weighted draw. The weights must sum to 1 and have one entry per element of the pool.

choice also accepts an int instead of an array, meaning "choose from arange(n)". This Is often what you want when you're really choosing indices.

Shuffling

rng.shuffle(a) modifies in place and returns None. rng.permutation(a) returns a shuffled copy.

On a 2-D array, both operate on the first axis. Whole rows move. Their contents stay intact. That is almost always what you want for a table of records. It's worth knowing rather than given it shuffles each element.

Splitting data

The correct pattern is one permutation, then slicing:

idx = rng.permutation(n)
train, test = idx[:cut], idx[cut:]

The tempting option — drawing a training sample and then a test sample — allows the same row into both. The two draws know nothing about each other. That leaks test data into training and inflates each score you measure later, at times sharply. The code gives no sign anything is wrong.

Permuting once makes the disjointness structural rather than random. The same index array applied to X and y keeps them aligned.

Reproducibility in practice

Seed at the entry point, not scattered through the code. Record the seed alongside results. Pass the gen by name into any function that needs noise. It can be tested with a fixed one.

And be clear about what a seed promises. Same results for the same NumPy version and the same sequence of calls. Change the order of steps and the numbers change, even with the seed unchanged.

What is underneath a Generator

default_rng returns a gen wrapping a bit gen. This Is the thing that really makes random bits. The default is PCG64.

The split matters because the two layers have other jobs. The bit gen makes a stream of uniform bits. The gen turns those into spreads. Swapping the bit gen. default_rng(np.random.MT19937(seed)) for the old Mersenne Twister, or Philox for a counter-based one. Changes the stream without changing the interface.

PCG64 was chosen as the default because it's fast, has good statistical properties. Supports cheap split streams. Mersenne Twister, the legacy default, is slower and has a much larger state.

None of these are cryptographically secure. For tokens, passwords or anything an adversary shouldn't predict, use the secrets module, not NumPy.

Independent streams

Running the same modelling in parallel needs each worker to draw other numbers. Seeding each with a other any int isn't a solid way to get that. Nearby seeds can give shared streams.

SeedSequence is the supported answer:

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

Each child makes a stream that is statistically split of the others. The whole set is reproducible from the single parent seed.

rng.spawn(n) on a gen does the same thing more right in recent NumPy versions.

This is the right way to seed workers in a multiprocessing pool. It means one recorded seed copies the entire parallel run.

The legacy interface, and when you need it

np.random.RandomState is the old object-oriented interface. The global functions like np.random.rand are methods on a hidden instance of it.

It's frozen for backwards match: its stream is sure never to change across NumPy versions. gen carries no such promise. Its output can change between versions if an method is better.

That makes RandomState the correct choice in just one situation. Copying results from older code or published work where the exact numbers matter.

For all else, gen is faster, has better spreads. Doesn't share state with every other piece of code in the process.

Choosing a distribution

The ones that come up most, and what they model:

uniform(low, high) — no data beyond a range.

normal(loc, scale) — readings with even error, sums of many small split effects.

lognormal — quantities that can't be minus and are scaling: incomes, file sizes, response times.

poisson(lam) — counts of split events in a fixed interval: arrivals, defects, requests per second.

steep(scale) — waiting times between Poisson events.

binomial(n, p) — successes out of n trials.

Using normal for a quantity that can't be minus is a common modelling error. It shows up as minus durations or minus counts in made test data. lognormal or a truncated spread is often what was meant.

Generating realistic test data

A few patterns worth having.

Correlated variables: rng.multivariate_normal(mean, cov, size=n) draws from a specified covariance structure, which is how you produce test data where the relationships are known.

Categorical with known proportions: rng.choice(categories, size=n, p=weights).

Sorted timestamps: draw uniformly and sort, or accumulate exponential gaps with cumsum for a Poisson process.

Reproducible shuffling of an existing dataset: rng.permutation(len(data)) applied as an index, never two independent draws.

Reproducibility, honestly

A seed promises the same numbers for the same NumPy version, the same gen. The same sequence of calls.

Change the order of steps and the numbers change. Add a draw in the middle and all after it shifts. Parallelise differently and the set of numbers to workers changes.

This means a seed copies a run, not a result. Code that draws other amounts of noise based on the data won't copy across changes to that data, even with the seed fixed.

The hands-on discipline: seed once at the entry point, record the seed alongside the output, pass the gen by name into anything that needs it. Use SeedSequence for parallel work. Those four habits cover almost each reproducibility problem people really hit.

Passing the generator around

The habit that makes noise testable is to accept a gen as an arg rather than creating one inside a function.

def make_sample(n, rng):
    return rng.normal(size=n)

The caller decides the seed. So The same function serves production. Where the gen is seeded from the operating system. And tests. Here A fixed seed makes assertions possible.

A default of rng=None with rng = np.random.default_rng(rng) inside is a handy pattern: default_rng accepts a gen, an int seed, a SeedSequence or None. Returns a gen in each case. That gives callers all four options with one line.

The option — calling np.random.seed one spot and hoping — is what makes randomised code untestable and non-reproducible. Any other code drawing from the same global stream changes the results.

Randomness in tests

A test that uses random data and a fixed seed is testing one sample. That is often fine. It's better than a test with hard-coded magic numbers.

The failure mode to avoid is a test that passes for most seeds and fails for some. If a test is checking a statistical property. That a mean is near zero, that a split is roughly weighed. The handling has to be wide enough for the spread, not tuned until the current seed passes.

np.allclose with an clear handling expresses that far better than exact compare. Stating the handling forces the question of how much variation is acceptable.

For anything where the property should hold for all inputs rather than one sample, property-based testing with a few seeds is the honest version.

Common mistakes with Random Numbers

Seeding inside a loop. makes the same value each loop. The symptom is oddly uniform data.

**Using np.random.seed in a library.** It mutates global state that belongs to the use, not to you.

Sampling twice for a train/test split. The two draws are split. Rows can appear in both. Permute once.

**Using normal for a non-minus quantity.** makes minus durations and minus counts. lognormal or a truncated spread is often what was meant.

**Expecting shuffle to return a thing.** It modifies in place and returns None. a = rng.shuffle(a) sets a to None. permutation is the returning version.

given a seed copies a result rather than a run. Change the order of draws and the numbers change, seed or no seed.

The summary

Create one gen with default_rng(seed) at the entry point.

Pass it by name to anything that needs noise.

Use SeedSequence or spawn for parallel workers, never nearby int seeds.

Draw whole arrays with size rather than looping.

Permute an index array once when splitting or shuffling aligned data.

Record the seed with the results. Note that it copies the sequence of calls, not the conclusion.

A closing note on Random Numbers

Noise is one of the few areas where the convenient API and the correct API differ. Where the convenient one has been the default for long enough that most examples still use it.

np.random.seed and its companions aren't broken. Existing code using them doesn't need rewriting. But new code has a better option, and the reasons. Alone, thread safety, reproducible parallelism. Are the kind that only matter once, catastrophically, at the point where results can't be copied.

One gen, created by name, passed where it's needed. That single habit covers almost all.

Questions people ask about Random Numbers

How do I make random results reproducible? Create a generator with rng = np.random.default_rng(42) and draw from it. The same seed gives the same stream.

What is wrong with np.random.seed? It sets hidden global state shared by everything in the process. The newer Generator API keeps state in an object you pass around.

How do I shuffle or sample without replacement? Use rng.permutation(n) to shuffle, or rng.choice(a, size=k, replace=False) to sample. rng.shuffle works in place.

How do I draw from a normal distribution? Call rng.normal(loc, scale, size). Note that scale is the standard deviation, not the variance.

Is the output truly random? It is pseudorandom: deterministic given the seed but statistically well behaved. It is fine for simulation and not for cryptography.

How do I get independent streams in parallel jobs? Use SeedSequence.spawn to create child seeds. Do not just add one to the seed for each worker.

One last habit

Record the seed with the output, not just in the code.

A seed that lives only in a source file is lost the moment the file changes. The results it made become unreproducible without a git archaeology session. Writing it into the output. A filename, a metadata field, a log line. Costs nothing and is the gap between results that can be regenerated and results that can only be trusted.

Check yourself

0 of 4

Answer without scrolling back up.

  1. Why is `default_rng` preferred over `np.random.seed`?

  2. What does `rng.integers(1, 3, size=8)` produce?

  3. What does `rng.shuffle(a)` do to a 2-D array?

  4. What is wrong with drawing a train sample and then a test sample separately?

Cheat sheet

Random Numbers

Generate random integers, floats and samples with NumPy's Generator API. Set a seed for reproducible results and avoid the legacy functions.

NUMPY · vizlearn.in/numpy/random_numbers.html

About the author

Ashish Jangra builds and maintains VizLearn. Every module here is written and the visualisation behind it hand-built, so the numbers in a readout come from the same code that draws the picture. Corrections are genuinely welcome and get priority over everything else — if a page states something wrong, or an animation misrepresents what the algorithm does, get in touch.