Python randint(): Both Ends Included (and the NumPy Trap)

randint returns a random whole number between two values, and unusually for Python, both ends are included:

import random

random.randint(1, 10)      # any whole number from 1 to 10, 10 included

That inclusive upper bound is the thing to remember. range, slicing and randrange all stop one short, and randint does not.

NumPy’s identically named np.random.randint does stop short, which is where most bugs come from.

All output below is from real runs on Python 3.12.5, NumPy 2.5.3.

Using random.randint in Python

Import random, then call it with the lowest and highest values you want:

import random

print(random.randint(1, 10))        # a whole number from 1 to 10
print(random.randint(1, 10))
print(random.randint(-5, 5))        # negatives are fine
print(random.randint(7, 7))         # a range of one always gives 7

# both ends are included, so 1 and 10 can both come up
draws = [random.randint(1, 10) for _ in range(20)]
print("\n20 draws:", draws)

Output:

4
5
1
7

20 draws: [7, 7, 5, 2, 4, 2, 10, 7, 2, 1, 9, 8, 6, 2, 1, 1, 10, 7, 9, 6]
  • Both arguments must be whole numbers. A float raises TypeError.
  • The first must not be larger than the second.
  • randint(7, 7) is legal and always returns 7.

There is no step argument. If you need every third number, use random.randrange(0, 100, 3) instead.

Does randint include the endpoints?

Yes, both of them, and it is worth proving rather than trusting:

import random

draws = [random.randint(1, 10) for _ in range(200_000)]

print("lowest seen :", min(draws))
print("highest seen:", max(draws), "<- 10 is reachable")

print()
# randrange stops one short, like range() does
stops = [random.randrange(1, 10) for _ in range(200_000)]
print("randrange(1, 10) highest:", max(stops), "<- 10 is NOT reachable")

print()
print("randint(a, b)    includes both a and b")
print("randrange(a, b)  includes a, excludes b")

Output:

lowest seen : 1
highest seen: 10 <- 10 is reachable

randrange(1, 10) highest: 9 <- 10 is NOT reachable

randint(a, b)    includes both a and b
randrange(a, b)  includes a, excludes b
Command Prompt showing that Python randint reaches the upper bound 10 while randrange stops at 9
200,000 draws each. randint reaches 10, randrange stops at 9.
CallPossible values
random.randint(1, 10)1 to 10
random.randrange(1, 10)1 to 9
np.random.randint(1, 10)1 to 9
range(1, 10)1 to 9

randint is the odd one out. Everything else in that table follows the half-open convention.

random.randint vs np.random.randint

The names match, the behaviour does not. Swapping one for the other silently changes your results:

import random
import numpy as np

py = [random.randint(1, 10) for _ in range(100_000)]
npy = np.random.randint(1, 10, 100_000)

print("random.randint(1, 10)     max:", max(py))
print("np.random.randint(1, 10)  max:", npy.max(), "<- different!")

print()
# to get 1..10 from numpy you have to ask for 11
fixed = np.random.randint(1, 11, 100_000)
print("np.random.randint(1, 11)  max:", fixed.max())

Output:

random.randint(1, 10)     max: 10
np.random.randint(1, 10)  max: 9 <- different!

np.random.randint(1, 11)  max: 10
Command Prompt comparing random.randint reaching 10 against numpy random randint stopping at 9
100,000 draws each, and NumPy never produces the 10.

To get 1 to 10 out of NumPy you must write np.random.randint(1, 11). The high value is exclusive.

This catches people porting a loop to NumPy for speed. For more on the NumPy side, see NumPy random number generation.

Is randint uniform?

Every value in the range is equally likely. A 60,000-roll histogram makes that visible:

import random
import matplotlib.pyplot as plt

random.seed(1)
draws = [random.randint(1, 6) for _ in range(60_000)]

plt.figure(figsize=(8, 4.2))
plt.hist(draws, bins=range(1, 8), align="left", rwidth=.75, color="#0b6bcb")
plt.axhline(10_000, color="#c0392b", ls="--", lw=1.5, label="expected 10,000 each")
plt.title("randint(1, 6) is uniform: every face equally likely")
plt.xlabel("value"); plt.ylabel("times drawn")
plt.xticks(range(1, 7))
plt.legend(); plt.grid(axis="y", alpha=.3); plt.tight_layout()
plt.show()

from collections import Counter
counts = Counter(draws)
for face in range(1, 7):
    print(f"  {face}: {counts[face]:>6,}")

Output:

  1: 10,053
  2: 10,079
  3:  9,888
  4: 10,102
  5: 10,071
  6:  9,807
Histogram of 60,000 Python randint dice rolls showing all six values occurring roughly equally often
Each face lands close to the expected 10,000, with only sampling noise between them.

The bars will not be exactly level, and they should not be. Perfectly equal counts would mean the numbers were not random.

Seeding randint for reproducible results

random.seed makes the sequence repeatable, which matters for tests and for anything a reader should be able to reproduce:

import random

random.seed(42)
first = [random.randint(1, 100) for _ in range(5)]

random.seed(42)                      # same seed, same sequence
second = [random.randint(1, 100) for _ in range(5)]

print("run 1:", first)
print("run 2:", second)
print("identical:", first == second)

print()
# without a seed you get a different sequence every run
random.seed()
print("unseeded:", [random.randint(1, 100) for _ in range(5)])

Output:

run 1: [82, 15, 4, 95, 36]
run 2: [82, 15, 4, 95, 36]
identical: True

unseeded: [7, 64, 89, 68, 93]

Same seed, same numbers, every time and on every machine. That is the whole point of a seed.

Call random.seed() with no argument to go back to unpredictable output, which is the default at startup.

Common randint errors

Three mistakes cover almost every traceback:

import random

attempts = [
    ("randint(10)",      lambda: random.randint(10)),
    ("randint(10, 1)",   lambda: random.randint(10, 1)),
    ("randint(1.5, 10)", lambda: random.randint(1.5, 10)),
    ("randint(1, 10)",   lambda: random.randint(1, 10)),
]

for label, call in attempts:
    try:
        print(f"{label:<18} -> {call()}")
    except Exception as err:
        print(f"{label:<18} -> {type(err).__name__}: {err}")

Output:

randint(10)        -> TypeError: Random.randint() missing 1 required positional argument: 'b'
randint(10, 1)     -> ValueError: empty range in randrange(10, 2)
randint(1.5, 10)   -> TypeError: 'float' object cannot be interpreted as an integer
randint(1, 10)     -> 6
Command Prompt showing TypeError and ValueError raised by incorrect Python randint calls
Missing argument, reversed range, and a float bound.
CallErrorWhy
randint(10)TypeErrorIt needs two arguments, not one
randint(10, 1)ValueErrorThe range runs backwards
randint(1.5, 10)TypeErrorBounds must be whole numbers

The reversed-range message mentions randrange rather than randint, which is confusing. It happens because randint is a thin wrapper around randrange.

For a random decimal rather than a whole number, use random.uniform(1.5, 10).

Generating lists of random numbers

A comprehension covers most cases, but the random module has better tools for some of them:

import random

# several random numbers at once
print("10 dice rolls  :", [random.randint(1, 6) for _ in range(10)])

# unique values: sample picks without replacement
print("6 unique 1-49  :", random.sample(range(1, 50), 6))

# a random item rather than a random number
colours = ["red", "green", "blue", "yellow"]
print("random colour  :", random.choice(colours))

# shuffling in place
deck = list(range(1, 11))
random.shuffle(deck)
print("shuffled       :", deck)

Output:

10 dice rolls  : [3, 4, 3, 2, 6, 1, 5, 1, 6, 3]
6 unique 1-49  : [25, 38, 36, 31, 11, 24]
random colour  : red
shuffled       : [4, 5, 10, 3, 2, 8, 9, 1, 6, 7]
Command Prompt showing Python randint in a list comprehension alongside sample choice and shuffle
Repeats allowed on the left, unique picks with sample.

random.sample is the one to reach for when values must not repeat, such as lottery numbers. A randint loop can return the same number twice. There are more approaches in generating a list of random numbers.

When not to use randint

Two cases call for something else entirely:

import random
import secrets
import timeit

# randint is predictable: it is NOT safe for passwords, tokens or OTPs
print("secrets.randbelow(100):", [secrets.randbelow(100) for _ in range(8)])
print("secrets.choice        :", secrets.choice("ABCDEFGHJKMNPQRSTUVWXYZ23456789"))

print()
# and for bulk generation, numpy is far faster than a loop
import numpy as np
loop = timeit.timeit(lambda: [random.randint(1, 100) for _ in range(1000)], number=100)
bulk = timeit.timeit(lambda: np.random.randint(1, 101, 1000), number=100)
print(f"1,000 numbers, 100 times:")
print(f"  random.randint loop  {loop * 1000:>7.0f} ms")
print(f"  np.random.randint    {bulk * 1000:>7.1f} ms")

Output:

secrets.randbelow(100): [53, 97, 77, 2, 91, 36, 37, 59]
secrets.choice        : 8

1,000 numbers, 100 times:
  random.randint loop       23 ms
  np.random.randint       11.4 ms

random uses a Mersenne Twister, which is fast and repeatable but completely predictable once an attacker sees enough output.

For passwords, tokens, OTPs and session IDs, use the secrets module. It costs nothing extra to type and closes a real hole.

For bulk generation, NumPy is roughly 24 times faster than a Python loop because it fills the whole array in one call. That matters as soon as you’re generating random numbers by the thousand.

More Python random number guides:

Frequently asked questions

What does randint do in Python?

random.randint(a, b) returns a random whole number between a and b, with both endpoints included. It is documented in the Python random module reference.

Does randint include the upper number?

Yes. random.randint(1, 10) can return 10. This is different from range and randrange, which both stop one short.

What is the difference between randint and randrange?

randint(a, b) includes b. randrange(a, b) excludes it, and also accepts a step argument that randint does not have.

Why does np.random.randint give different results?

NumPy’s version excludes the high value. np.random.randint(1, 10) never returns 10, so you need np.random.randint(1, 11) to match random.randint(1, 10).

How do I make randint give the same numbers every time?

Call random.seed(42) before generating. The same seed always produces the same sequence.

Why does randint(10, 1) raise a ValueError?

The range runs backwards. The message mentions randrange because randint calls it internally.

Is random.randint secure for passwords?

No. It is predictable once enough output is observed. Use the secrets module for passwords, tokens and OTPs.