Working with Random Numbers in NumPy

numpy random numbers show up constantly in real NumPy work — simulations, shuffling data before training a machine learning model, sampling, generating synthetic test data. This article teaches the modern numpy random Generator API throughout, rather than the older, global-state approach many still-ranking tutorials teach — NumPy's own documentation actively recommends the newer API for all new code, and this article follows that recommendation from the very first example.

NumPy's Random Module, Old and New

Why random number generation matters constantly

Random numbers underpin a huge share of everyday NumPy work: running simulations, shuffling a dataset before splitting it into training and test sets, sampling a subset of data, and generating synthetic test data for development and debugging.

Two APIs, one recommended

NumPy actually has two distinct random-number APIs, and it's worth being upfront about this: a legacy, global-state approach (np.random.seed(), np.random.rand()) that you'll still encounter constantly in older code and older tutorials, and a modern, recommended Generator API, introduced in NumPy 1.17 and actively recommended for all new code since. This article uses the modern API throughout — deliberately correcting a gap where a lot of still-highly-ranked content continues teaching the older, legacy pattern.

What this article covers

Creating a Generator, common distributions and sampling methods, seeding for genuine reproducibility, and sampling without replacement — plus, in Section 5, best practices for genuinely strong seeds and parallel-safe random generation.

Creating a Generator with default_rng()

The modern, recommended starting point
import numpy as np

rng = np.random.default_rng(seed=12345)

np.random.default_rng(seed) is the recommended entry point for essentially all new NumPy code involving randomness. It's both faster and statistically better than the legacy API — the underlying algorithm (PCG64 by default) has genuinely better statistical properties than the older Mersenne Twister-based legacy generator.

Generating random integers
rints = rng.integers(low=0, high=10, size=3)
print(rints)   # e.g., [7 2 9]

rng.integers(low, high, size) returns an array of random integers, drawn from low (inclusive) up to high (exclusive by default), in whatever size you request.

Generating random floats in a specific shape
random_matrix = rng.random((3, 3))
print(random_matrix)
# a 3x3 array of floats, each between 0 and 1

rng.random(shape) produces floats uniformly distributed between 0 and 1 — and passing a tuple as the shape argument demonstrates that the generator naturally supports multidimensional output directly, not just flat 1D arrays. This mirrors exactly the shape conventions covered throughout the earlier NumPy articles in this series.

Seeding for Reproducibility

Why seeding matters

A random seed initializes a random number generator's internal state, and — critically — that initial state fully determines every subsequent "random" number the generator will ever produce. This makes results genuinely reproducible: run the same code with the same seed, and you get the exact same sequence of numbers every single time, despite the values technically being called "random."

This is essential for machine learning experiments (where you need to compare two model configurations fairly, without random variation between runs muddying the comparison) and for debugging (where you need a bug involving random data to reproduce consistently, rather than appearing and disappearing unpredictably between runs).

Concrete proof of reproducibility
# Run 1
rng = np.random.default_rng(seed=42)
print(rng.integers(0, 100, size=5))
# [8 77 65 43 43]
# Restart the Python interpreter entirely, then run this fresh
rng = np.random.default_rng(seed=42)
print(rng.integers(0, 100, size=5))
# [8 77 65 43 43] — the exact same sequence, every time

Even across a completely fresh interpreter session, the same seed produces the identical sequence of "random" numbers — direct, concrete proof that the seed alone fully determines the entire output sequence, with nothing else (system time, hardware state) factoring in.

Why isolated generators beat the legacy global approach

The legacy np.random.seed() sets global state — a single, shared random state affecting every subsequent call to any legacy np.random.* function, anywhere in your program, including inside libraries you didn't write yourself.

# Legacy approach — sets GLOBAL state, affecting everything afterward
np.random.seed(42)
print(np.random.rand(3))

np.random.default_rng(), by contrast, creates a genuinely isolated generator object — its state belongs only to that specific rng variable, with zero effect on any other part of your codebase, and zero risk of some other piece of code (a library, a different module) unexpectedly resetting or consuming from the same shared global state.

# Modern approach — isolated, doesn't touch any global state
rng1 = np.random.default_rng(seed=42)
rng2 = np.random.default_rng(seed=99)
# rng1 and rng2 are completely independent, and neither affects any other code

This isolation is critical the moment different parts of a codebase — or genuinely different, concurrent processes — need independent random streams that don't interfere with each other, a topic covered further in Section 5.

Common Distributions and Sampling Methods

The core method set for most everyday tasks
rng = np.random.default_rng(seed=1)

uniform_floats = rng.random(5)                        # uniform floats between 0 and 1
random_ints = rng.integers(1, 7, size=5)                # integers, e.g. simulating dice rolls
normal_values = rng.normal(loc=0, scale=1, size=5)       # normally (Gaussian) distributed values
random_choice = rng.choice(["a", "b", "c"], size=3)      # random selection from a given set

print(uniform_floats)
print(random_ints)
print(normal_values)
print(random_choice)

rng.random(), rng.integers(), rng.normal(), and rng.choice() together cover the overwhelming majority of everyday random-number needs: uniform floats, integers within a range, values drawn from a normal (Gaussian) distribution (parameterized by loc, the mean, and scale, the standard deviation), and random selection from an existing collection of values.

shuffle vs. permutation: a common source of subtle bugs

This distinction genuinely trips people up, and it's worth being precise about:

arr = np.array([1, 2, 3, 4, 5])

rng.shuffle(arr)   # modifies arr IN PLACE
print(arr)   # e.g., [3 1 5 2 4] — the original array itself has been reordered
arr = np.array([1, 2, 3, 4, 5])

shuffled = rng.permutation(arr)   # returns a NEW shuffled array
print(arr)         # [1 2 3 4 5] — completely unchanged
print(shuffled)     # e.g., [3 1 5 2 4] — a separate, new array

rng.shuffle() modifies the given array in place, and returns None — a genuinely common mistake is writing arr = rng.shuffle(arr), which silently sets arr to None, since shuffle() never actually returns the shuffled array itself. rng.permutation() instead returns a brand-new, independently shuffled array, leaving the original completely untouched — mirroring exactly the mutating-method-vs-new-object distinction covered in the earlier lists article (.sort() vs. sorted()).

Sampling without replacement
dataset = np.arange(100)   # imagine 100 records

train_indices = rng.choice(dataset, size=80, replace=False)
print(len(train_indices))          # 80
print(len(set(train_indices)))      # 80 — confirms no duplicates at all

replace=False tells rng.choice() to sample without replacement — meaning once a value has been selected, it can't be selected again in the same call. This is exactly the mechanism you'd use for randomly splitting a dataset into training and test sets: sampling 80% of record indices without replacement guarantees every selected record is genuinely unique, with zero risk of the same record accidentally appearing in your training set twice.

Best Practices: Strong Seeds and Parallel-Safe Generation

Picking a genuinely strong seed

Using a small, predictable seed value (0, 1, 42) is fine for everyday examples and tutorials, but for genuinely rigorous, reproducible work — particularly anything with real statistical stakes — it's worth using a much larger, more genuinely random seed value, rather than a small, easily-guessed number:

import secrets

str secrets.randbits(128)
rng = np.random.default_rng(strong_seed)

print(strong_seed)   # save this value if you need to reproduce this exact run later

secrets.randbits(128) generates a genuinely random 128-bit integer — a value large and unpredictable enough to be considered sufficiently strong for seeding purposes, in contrast to a small, easily-guessed seed value. The key practical habit: save the actual seed value you used somewhere (a log file, a config, a printed record) if reproducing that exact run later genuinely matters — the seed itself is what needs to be recorded, not just "yes, we used a random seed."

Parallel and reproducible workflows: spawning child generators

This connects directly back to the concurrency articles earlier in this series: if you're generating random data across multiple threads or processes simultaneously, you genuinely need independent, non-interfering random streams for each worker — naively sharing one Generator instance across concurrent workers isn't safe, since it isn't designed for concurrent access from multiple workers at once.

parent_rng = np.random.default_rng(seed=42)
child_generators = parent_rng.spawn(2)   # produces 2 independent child generators

worker1_rng, worker2_rng = child_generators

print(worker1_rng.integers(0, 100, size=3))
print(worker2_rng.integers(0, 100, size=3))

rng.spawn(n) produces n genuinely independent child generators, each with statistically independent random streams, derived deterministically from the parent — meaning the whole spawned set remains fully reproducible from the parent's original seed, while still giving each individual worker (thread, process) its own separate, non-conflicting generator to use safely.

Closing guidance

Tying this back to the earlier threading and multiprocessing articles: when generating random data across multiple threads or processes, always use separate Generator instances — ideally spawned from a common parent via .spawn(), as shown above — rather than sharing one generator across workers, or naively re-seeding the same seed value in every worker (which would produce the exact same "random" sequence in every worker, defeating the entire purpose of parallelizing the work in the first place). Getting this right is genuinely important the moment your random-number-generating code moves from a single-threaded script into any kind of concurrent or parallel execution context.