NumPy Random: Numbers, Ranges, Seeds and Arrays

NumPy random numbers come from a generator you create once and then ask for values:

import numpy as np

rng = np.random.default_rng()
rng.random()            # a decimal between 0 and 1
rng.integers(0, 10)     # a whole number, 0 to 9

The older np.random.rand() and np.random.randint() still work and appear in most tutorials. They differ in one respect that matters, covered further down.

Starting with the question most people arrive with: a random number in a given range. Output is from Python 3.12.5, NumPy 2.5.3.

Generate a random number between 0 and 1

rng.random() is the direct answer:

import numpy as np

rng = np.random.default_rng()

print("one random number between 0 and 1:")
print(" ", rng.random())
print()
print("five of them:")
print(" ", rng.random(5))
print()
print("the range is half-open. 0.0 can come up, 1.0 never does.")

Output:

one random number between 0 and 1:
  0.2626879481907136

five of them:
  [0.15949681 0.23139859 0.35870114 0.66405422 0.90211386]

the range is half-open. 0.0 can come up, 1.0 never does.
Command Prompt showing NumPy generating a random number between 0 and 1 with the random method
rng.random() returns a decimal in the range 0 to 1.

The interval is half-open, written [0, 1). Zero is possible, one is not. That convention lets you scale the result into any range without the top value landing twice as often.

Passing a number returns that many values as an array, which is far quicker than calling it in a loop.

Generate a random number between 0 and 10, or any range

Whole numbers come from integers() and decimals from uniform():

import numpy as np

rng = np.random.default_rng()

print("a whole number between 0 and 10:")
print("  rng.integers(0, 10)        ->", rng.integers(0, 10), " 10 itself is excluded")
print("  rng.integers(0, 10, endpoint=True) ->", rng.integers(0, 10, endpoint=True), " 10 can appear")
print()
print("a decimal between 5 and 10:")
print("  rng.uniform(5, 10)         ->", round(rng.uniform(5, 10), 4))
print()
print("several at once, by passing a size:")
print("  rng.integers(1, 7, 10)     ->", rng.integers(1, 7, 10), " ten dice rolls")
print("  rng.uniform(-1, 1, 5)      ->", np.round(rng.uniform(-1, 1, 5), 3))
print()
print("scaling by hand gives the same thing as uniform:")
print("  5 + rng.random(3) * 5      ->", np.round(5 + rng.random(3) * 5, 4))

Output:

a whole number between 0 and 10:
  rng.integers(0, 10)        -> 5  10 itself is excluded
  rng.integers(0, 10, endpoint=True) -> 2  10 can appear

a decimal between 5 and 10:
  rng.uniform(5, 10)         -> 8.9223

several at once, by passing a size:
  rng.integers(1, 7, 10)     -> [2 3 4 2 2 3 3 6 2 5]  ten dice rolls
  rng.uniform(-1, 1, 5)      -> [-0.256  0.315  0.268  0.814  0.533]

scaling by hand gives the same thing as uniform:
  5 + rng.random(3) * 5      -> [7.7377 5.9373 8.6872]
Command Prompt showing NumPy random integers and uniform values generated between two bounds
integers() for whole numbers, uniform() for decimals.

The upper bound is excluded by default, so rng.integers(0, 10) gives 0 to 9. Add endpoint=True when you want 10 included, which is what people usually mean by “between 0 and 10”.

You wantCall
A decimal, 0 to 1rng.random()
A decimal, 5 to 10rng.uniform(5, 10)
A whole number, 0 to 9rng.integers(0, 10)
A whole number, 0 to 10rng.integers(0, 10, endpoint=True)
A dice rollrng.integers(1, 7)
Ten of themAdd a size: rng.integers(1, 7, 10)

Scaling rng.random() by hand gives exactly what uniform() gives, so use whichever reads better. For evenly spaced rather than random values, np.linspace is the tool.

default_rng() and the legacy np.random functions

Two APIs exist, and both are supported:

import numpy as np

# the current way: make a generator, then ask it for numbers
rng = np.random.default_rng()

print("rng            ->", type(rng).__name__)
print("rng.random()   ->", rng.random())
print("rng.integers(0, 100) ->", rng.integers(0, 100))
print("rng.normal(0, 1)     ->", round(rng.normal(0, 1), 4))
print()

# the older way, still supported
print("the legacy functions still work:")
print("np.random.rand()      ->", np.random.rand())
print("np.random.random()    ->", np.random.random())
print("np.random.randint(0, 100) ->", np.random.randint(0, 100))
print()
print("Both give random numbers. The difference is where the state lives:")
print("a Generator holds its own, the legacy functions share one global.")

Output:

rng            -> Generator
rng.random()   -> 0.9754511878847149
rng.integers(0, 100) -> 30
rng.normal(0, 1)     -> 0.2144

the legacy functions still work:
np.random.rand()      -> 0.9169473039095801
np.random.random()    -> 0.706797744272383
np.random.randint(0, 100) -> 77

Both give random numbers. The difference is where the state lives:
a Generator holds its own, the legacy functions share one global.

np.random.default_rng() is the newer one. You make a generator and call methods on it. The legacy functions are module-level and share a single global state behind the scenes.

New code should use default_rng(). The legacy functions are not going away, so existing code does not need rewriting, but the global state causes the problem shown in the next section.

Seeding for reproducible random numbers

A seed makes the sequence repeatable:

import numpy as np

first = np.random.default_rng(7).integers(0, 100, 5)
second = np.random.default_rng(7).integers(0, 100, 5)

print("default_rng(7) twice:")
print("  first  ->", first)
print("  second ->", second)
print("  identical ->", np.array_equal(first, second))
print()
print("a seed makes the sequence repeatable, which is what you want")
print("for tests, tutorials and anything a colleague must reproduce.")
print()

print("without a seed you get something different every run:")
print(" ", np.random.default_rng().integers(0, 100, 5))
print(" ", np.random.default_rng().integers(0, 100, 5))

Output:

default_rng(7) twice:
  first  -> [94 62 68 89 57]
  second -> [94 62 68 89 57]
  identical -> True

a seed makes the sequence repeatable, which is what you want
for tests, tutorials and anything a colleague must reproduce.

without a seed you get something different every run:
  [22 80 76 63 47]
  [21 13  1 38 92]
Command Prompt showing that NumPy default_rng with the same seed produces identical random numbers
The same seed gives the same numbers every time.

Pass any integer. The numbers are still statistically random; they are just the same random numbers each run, which is what makes a tutorial or a test reproducible.

Leave the seed out for genuinely different values on every run. That is the right choice for simulations and for anything where repeatability would be a flaw.

Why the global seed causes trouble

This is the practical reason to prefer a generator:

import numpy as np

print("the legacy seed is GLOBAL, so anything can disturb it:")
np.random.seed(1)
first = np.random.rand()

np.random.seed(1)
np.random.rand()          # imagine a library called this
second = np.random.rand()

print("  seeded, first value        ->", first)
print("  seeded, then something else ran, next value ->", second)
print("  same? ->", first == second)
print()
print("a Generator has its own state, so nothing else can move it:")
mine = np.random.default_rng(1)
theirs = np.random.default_rng(99)
print("  mine.random()   ->", mine.random())
theirs.random()           # a different generator, doing its own thing
print("  mine.random()   ->", mine.random(), " unaffected by theirs")

Output:

the legacy seed is GLOBAL, so anything can disturb it:
  seeded, first value        -> 0.417022004702574
  seeded, then something else ran, next value -> 0.7203244934421581
  same? -> False

a Generator has its own state, so nothing else can move it:
  mine.random()   -> 0.5118216247002567
  mine.random()   -> 0.9504636963259353  unaffected by theirs
Command Prompt comparing the NumPy global random seed with an independent Generator
Anything can consume the global stream; a Generator keeps its own.

np.random.seed() sets one shared state. Any library that draws a number afterwards shifts the sequence, so seeding no longer guarantees the values you expected.

A generator you created is yours. Another generator drawing numbers has no effect on it, which makes results reproducible even inside a large program.

Creating random arrays of a given shape

Both APIs make arrays, and this is where they differ most visibly:

import numpy as np

rng = np.random.default_rng(5)

print("a 2 by 3 array of decimals:")
print(rng.random((2, 3)))
print()
print("a 3 by 3 array of whole numbers:")
print(rng.integers(0, 10, (3, 3)))
print()

print("the legacy function wants separate arguments, not a tuple:")
print("  np.random.rand(2, 3) ->")
print(np.random.rand(2, 3))
try:
    np.random.rand((2, 3))
except TypeError as err:
    print("  np.random.rand((2, 3)) -> TypeError:", err)
print()
print("Generator methods take a shape tuple; legacy rand takes separate ints.")
print("That single difference causes a lot of confusion.")

Output:

a 2 by 3 array of decimals:
[[0.80500292 0.80794079 0.51532556]
 [0.28580138 0.0539307  0.38336888]]

a 3 by 3 array of whole numbers:
[[5 4 1]
 [0 0 0]
 [1 9 1]]

the legacy function wants separate arguments, not a tuple:
  np.random.rand(2, 3) ->
[[0.62526904 0.83310524 0.90376465]
 [0.43337143 0.08989722 0.38859955]]
  np.random.rand((2, 3)) -> TypeError: 'tuple' object cannot be interpreted as an integer

Generator methods take a shape tuple; legacy rand takes separate ints.
That single difference causes a lot of confusion.
Command Prompt showing the TypeError raised when passing a tuple to the legacy NumPy rand function
Generator methods take a shape tuple; legacy rand() takes separate integers.

rng.random((2, 3)) takes a tuple. np.random.rand(2, 3) takes the dimensions as separate arguments and raises TypeError if you pass a tuple.

Mixing the two up is a common first error. Once the array exists, its shape behaves like any other NumPy array.

Picking and shuffling with choice and shuffle

For sampling from data you already have:

import numpy as np

rng = np.random.default_rng(11)
names = np.array(["ann", "bob", "cat", "dan"])

print("pick one:")
print(" ", rng.choice(names))
print()
print("pick two, without picking the same one twice:")
print(" ", rng.choice(names, 2, replace=False))
print()
print("pick two, repeats allowed:")
print(" ", rng.choice(names, 2))
print()
print("weighted, so 'ann' comes up most:")
print(" ", rng.choice(names, 6, p=[0.7, 0.1, 0.1, 0.1]))
print()

deck = np.arange(8)
rng.shuffle(deck)
print("shuffle rearranges in place ->", deck)
print("permutation returns a new array ->", rng.permutation(8))

Output:

pick one:
  ann

pick two, without picking the same one twice:
  ['dan' 'ann']

pick two, repeats allowed:
  ['cat' 'cat']

weighted, so 'ann' comes up most:
  ['ann' 'ann' 'dan' 'ann' 'ann' 'dan']

shuffle rearranges in place -> [5 2 7 6 3 0 4 1]
permutation returns a new array -> [6 5 4 7 2 1 0 3]

replace=False prevents the same item being picked twice, which is what you want for drawing names or splitting data. The p argument sets the odds and must add up to 1.

shuffle() rearranges an array in place and returns nothing. permutation() leaves the original alone and hands back a new array, which is usually the safer choice.

Random numbers from a distribution

Not everything should be uniformly random:

import numpy as np

rng = np.random.default_rng(2)

print("uniform: every value equally likely")
print(" ", np.round(rng.uniform(0, 10, 6), 2))
print()
print("normal: clustered around the mean")
print("  mean 0, sd 1    ->", np.round(rng.normal(0, 1, 6), 3))
print("  mean 100, sd 15 ->", np.round(rng.normal(100, 15, 6), 1))
print()

sample = rng.normal(100, 15, 100_000)
print("over 100,000 draws from normal(100, 15):")
print("  actual mean ->", round(sample.mean(), 2))
print("  actual sd   ->", round(sample.std(), 2))
print()
print("the shape of the distribution is the point, not the individual numbers.")

Output:

uniform: every value equally likely
  [2.62 2.98 8.14 0.92 6.   7.29]

normal: clustered around the mean
  mean 0, sd 1    -> [-0.325  0.774  0.281 -0.554  0.978 -0.311]
  mean 100, sd 15 -> [ 95.1  88.1 106.8  98.5 108.2  90.9]

over 100,000 draws from normal(100, 15):
  actual mean -> 99.98
  actual sd   -> 14.99

the shape of the distribution is the point, not the individual numbers.

uniform() makes every value equally likely. normal(mean, sd) clusters values around the mean, which matches heights, measurement errors and most natural variation.

Over enough draws the sample mean and standard deviation converge on what you asked for, as the run above shows.

Histograms of 100,000 NumPy random draws from the random, normal and integers functions
100,000 draws from each: flat, clustered, and six equally likely values.

NumPy random quick reference

  • rng = np.random.default_rng() once, then call methods on it.
  • rng.random() for a decimal between 0 and 1.
  • rng.integers(low, high) for whole numbers, with high excluded.
  • rng.uniform(low, high) for decimals in a range.
  • default_rng(42) to make the sequence reproducible.
  • rng.choice(items, n, replace=False) to sample without repeats.
  • Generator methods take a shape tuple; legacy rand() takes separate integers.

Related NumPy guides:

Frequently asked questions

How do I generate a random number between 0 and 1 in NumPy?

Create a generator with rng = np.random.default_rng() and call rng.random(). The result is at least 0 and always below 1.

How do I get a random integer between 0 and 10?

rng.integers(0, 10) gives 0 to 9, since the top is excluded. Add endpoint=True to include 10.

What is the difference between np.random.rand and rng.random?

They both return decimals between 0 and 1. rng.random() belongs to a generator with its own state and takes a shape tuple; np.random.rand() uses global state and takes separate integers.

Should I use np.random.seed or default_rng?

default_rng(seed) for new code. np.random.seed() sets a global state that any other code can disturb, so seeding it does not reliably reproduce your numbers.

How do I make NumPy random numbers reproducible?

Pass a seed: np.random.default_rng(42). The same seed produces the same sequence on every run and on any machine.

How do I create a random array with a specific shape?

Pass the shape as a tuple: rng.random((2, 3)) or rng.integers(0, 10, (3, 3)). The legacy np.random.rand(2, 3) takes the dimensions separately.

How do I pick random items from a list without repeats?

rng.choice(items, n, replace=False). The generator API is described in the NumPy random Generator documentation.