For i in range() in Python: What It Means and How It Works

In Python, for i in range(5) runs the loop body five times, with i counting 0, 1, 2, 3, 4:

for i in range(5):
    print(i)        # 0, then 1, 2, 3 and 4

Two details trip people up. The number you pass is where the counting stops, and it is never itself included. And range does not give you a list, which is why printing it shows range(0, 5).

Both are explained below, along with for _ in range(), the comprehension form, and what happens to i after the loop ends. Output comes from Python 3.12.5.

Diagram showing the values i takes for range(5), range(2, 6) and range(0, 10, 2) in a Python for loop
The values i takes, and why the stop value never appears.

What for i in range() means in Python

range() produces a run of whole numbers, and the for loop hands them to i one at a time:

for i in range(5):
    print("i is now", i)

print()
print("the loop ran 5 times, and i counted 0, 1, 2, 3, 4")
print("range(5) means 'five numbers starting at zero', not 'up to five'")

Output:

i is now 0
i is now 1
i is now 2
i is now 3
i is now 4

the loop ran 5 times, and i counted 0, 1, 2, 3, 4
range(5) means 'five numbers starting at zero', not 'up to five'
Command Prompt showing a Python for i in range loop printing the values zero through four
range(5) gives five numbers, counting from zero.

i is an ordinary variable and the name is yours to choose. for number in range(5) behaves identically; i is simply short for index and has stuck by habit.

Counting from zero is deliberate. It lines the numbers up with list positions, so range(len(items)) covers exactly the valid indexes.

Why range(5) is not a list

Printing a range shows the rule rather than the numbers:

numbers = range(5)

print("range(5)        ->", numbers)
print("type            ->", type(numbers))
print("list(range(5))  ->", list(numbers))
print()
print("printing a range shows range(0, 5) rather than the numbers,")
print("because it stores the RULE, not the values.")
print()
print("it still behaves like a sequence:")
print("  len(range(0, 100, 7)) ->", len(range(0, 100, 7)))
print("  range(0, 100, 7)[3]   ->", range(0, 100, 7)[3])
print("  range(0, 100, 7)[-1]  ->", range(0, 100, 7)[-1])
print("  49 in range(0, 100, 7) ->", 49 in range(0, 100, 7))

Output:

range(5)        -> range(0, 5)
type            -> <class 'range'>
list(range(5))  -> [0, 1, 2, 3, 4]

printing a range shows range(0, 5) rather than the numbers,
because it stores the RULE, not the values.

it still behaves like a sequence:
  len(range(0, 100, 7)) -> 15
  range(0, 100, 7)[3]   -> 21
  range(0, 100, 7)[-1]  -> 98
  49 in range(0, 100, 7) -> True
Command Prompt showing that a Python range object prints as range(0, 5) rather than as a list of numbers
A range stores start, stop and step, so it prints as range(0, 5).

It stores three numbers and works the rest out on demand. Wrap it in list() when you genuinely want the values, which is mostly just for looking at them.

Despite that, it still supports len(), indexing, negative indexing and in. You get sequence behaviour without the sequence ever existing.

range with start, stop and step

range() takes one, two or three arguments:

print("one argument  - where to stop:")
print("  range(5)          ->", list(range(5)))
print()
print("two arguments - where to start and stop:")
print("  range(2, 6)       ->", list(range(2, 6)))
print()
print("three          - start, stop and step:")
print("  range(0, 10, 2)   ->", list(range(0, 10, 2)))
print("  range(10, 0, -1)  ->", list(range(10, 0, -1)), " counting down")
print()
print("the stop value is never included:")
print("  range(1, 5)       ->", list(range(1, 5)), " no 5")
print("  range(5, 5)       ->", list(range(5, 5)), "     start equals stop, so nothing runs")
print()
print("to include the end, add one:")
print("  range(1, 5 + 1)   ->", list(range(1, 5 + 1)))

Output:

one argument  - where to stop:
  range(5)          -> [0, 1, 2, 3, 4]

two arguments - where to start and stop:
  range(2, 6)       -> [2, 3, 4, 5]

three          - start, stop and step:
  range(0, 10, 2)   -> [0, 2, 4, 6, 8]
  range(10, 0, -1)  -> [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]  counting down

the stop value is never included:
  range(1, 5)       -> [1, 2, 3, 4]  no 5
  range(5, 5)       -> []      start equals stop, so nothing runs

to include the end, add one:
  range(1, 5 + 1)   -> [1, 2, 3, 4, 5]
CallProducesReads as
range(5)0 1 2 3 4Five numbers from zero
range(2, 6)2 3 4 5From 2, stopping before 6
range(0, 10, 2)0 2 4 6 8From 0 to 10, every second
range(10, 0, -1)10 9 8 ... 1Counting down
range(5, 5)nothingStart equals stop, so the body never runs

Excluding the stop value is what makes range(len(items)) land exactly on the last valid index. To include an endpoint, add one to it.

Why range uses less memory than a list

This is the practical reason ranges are lazy:

import sys

as_range = range(1_000_000)
as_list = list(range(1_000_000))

print("range(1,000,000)        ->", sys.getsizeof(as_range), "bytes")
print("list(range(1,000,000))  ->", f"{sys.getsizeof(as_list):,}", "bytes")
print()
print("a range of a million numbers costs the same as a range of five,")
print("because it only remembers start, stop and step.")
print()
print("that is why looping over range() never runs out of memory:")
total = 0
for i in range(10_000_000):
    total += i
print("summed 10,000,000 numbers, total =", f"{total:,}")

Output:

range(1,000,000)        -> 48 bytes
list(range(1,000,000))  -> 8,000,056 bytes

a range of a million numbers costs the same as a range of five,
because it only remembers start, stop and step.

that is why looping over range() never runs out of memory:
summed 10,000,000 numbers, total = 49,999,995,000,000
Command Prompt comparing the memory size of a Python range object against the equivalent list of one million numbers
A million-number range costs 48 bytes; the list costs over 8 megabytes.

Forty-eight bytes covers a range of five numbers or a range of a million, because only start, stop and step are stored. The list has to hold every value at once.

That is why for i in range(10_000_000) runs happily while building the equivalent list would consume most of a gigabyte.

What for _ in range() means

When the counter is never used, the convention is to name it _:

# i is never used inside the body, so the name does not matter
for _ in range(3):
    print("hello")

print()
print("the underscore is a normal variable name with a convention attached:")
print("it tells a reader 'I am ignoring this value on purpose'.")
print()

# compare: here the value IS used, so give it a real name
for attempt in range(3):
    print("attempt number", attempt + 1)

print()
print("_ still holds a value, it is just not meant to be read:")
for _ in range(3):
    pass
print("_ is", _)

Output:

hello
hello
hello

the underscore is a normal variable name with a convention attached:
it tells a reader 'I am ignoring this value on purpose'.

attempt number 1
attempt number 2
attempt number 3

_ still holds a value, it is just not meant to be read:
_ is 2

The underscore is a normal variable. What it adds is a signal to whoever reads the code next: this loop repeats a fixed number of times, and the number itself does not matter.

When the value is used, give it a real name. for attempt in range(3) tells a reader more than for i in range(3) ever will.

The i for i in range() comprehension

This phrasing belongs to comprehensions rather than to loops:

# a loop that builds a list, written the long way
squares = []
for i in range(5):
    squares.append(i * i)
print("with a loop         ->", squares)

# the same thing as a comprehension
print("[i * i for i in range(5)] ->", [i * i for i in range(5)])
print()
print("the phrase 'i for i in range(5)' is the comprehension form,")
print("and on its own it just rebuilds the numbers:")
print("[i for i in range(5)] ->", [i for i in range(5)])
print("list(range(5))        ->", list(range(5)), " simpler, and identical")
print()
print("round brackets make a generator instead, which is not stored:")
print("sum(i * i for i in range(5)) ->", sum(i * i for i in range(5)))

Output:

with a loop         -> [0, 1, 4, 9, 16]
[i * i for i in range(5)] -> [0, 1, 4, 9, 16]

the phrase 'i for i in range(5)' is the comprehension form,
and on its own it just rebuilds the numbers:
[i for i in range(5)] -> [0, 1, 2, 3, 4]
list(range(5))        -> [0, 1, 2, 3, 4]  simpler, and identical

round brackets make a generator instead, which is not stored:
sum(i * i for i in range(5)) -> 30

A comprehension builds a list in one expression instead of appending in a loop. The i for i in range(5) part on its own produces the numbers unchanged, so list(range(5)) does the same job more plainly.

It earns its keep once the values are transformed or filtered, as in [i * i for i in range(5)]. Conditions inside one are covered in list comprehensions with if and else.

What happens to the loop variable i after the loop

The loop variable outlives the loop, which surprises people coming from other languages:

for i in range(3):
    pass

print("after the loop finishes, i is still here:", i)
print("it holds the LAST value the loop used, not 3")
print()

print("changing i inside the body does not change the iteration:")
seen = []
for i in range(4):
    seen.append(i)
    i = 99            # has no effect on the next round
print("  values the loop actually used ->", seen)
print()
print("each pass reassigns i from the range, so the 99 is thrown away.")
print()

print("an empty range means the body never runs:")
ran = False
for i in range(0):
    ran = True
print("  body executed?", ran)

Output:

after the loop finishes, i is still here: 2
it holds the LAST value the loop used, not 3

changing i inside the body does not change the iteration:
  values the loop actually used -> [0, 1, 2, 3]

each pass reassigns i from the range, so the 99 is thrown away.

an empty range means the body never runs:
  body executed? False
Command Prompt showing that the Python loop variable i keeps its last value after the for loop has finished
i survives the loop holding the final value it took.

Reassigning i inside the body has no effect on the iteration either. Each pass overwrites it from the range, so anything you put there is discarded.

Changing how many times the loop runs therefore means changing the range, not the variable.

Common range errors

Two messages account for most of them:

print("range only accepts whole numbers:")
try:
    range(2.5)
except TypeError as err:
    print("  range(2.5) -> TypeError:", err)

try:
    range(len([1, 2, 3]) / 2)
except TypeError as err:
    print("  range(3 / 2) -> TypeError:", err, " <- division gives a float")
print("  use // instead:", list(range(len([1, 2, 3]) // 2)))
print()

print("the step cannot be zero:")
try:
    range(1, 10, 0)
except ValueError as err:
    print("  range(1, 10, 0) -> ValueError:", err)
print()

print("a backwards range with a positive step is empty, not an error:")
print("  list(range(10, 0))    ->", list(range(10, 0)))
print("  list(range(10, 0, -1)) ->", list(range(10, 0, -1)))

Output:

range only accepts whole numbers:
  range(2.5) -> TypeError: 'float' object cannot be interpreted as an integer
  range(3 / 2) -> TypeError: 'float' object cannot be interpreted as an integer  <- division gives a float
  use // instead: [0]

the step cannot be zero:
  range(1, 10, 0) -> ValueError: range() arg 3 must not be zero

a backwards range with a positive step is empty, not an error:
  list(range(10, 0))    -> []
  list(range(10, 0, -1)) -> [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
Command Prompt showing the Python TypeError for a float passed to range and the ValueError for a zero step
A float and a zero step are the two errors range() raises.

The float error usually arrives through division, because / always produces a float in Python 3. Use // when you want a whole number back.

A backwards range with a positive step is not an error at all. range(10, 0) is simply empty, so the loop silently does nothing, which is a harder bug to spot than an exception.

When not to use range(len(…))

Looping over positions to look items up is a habit worth breaking:

fruits = ["apple", "pear", "plum"]

print("looping over the numbers, then looking each item up:")
for i in range(len(fruits)):
    print("  ", i, fruits[i])

print()
print("enumerate does both at once, and reads better:")
for i, fruit in enumerate(fruits):
    print("  ", i, fruit)

print()
print("and if you do not need the position at all:")
for fruit in fruits:
    print("  ", fruit)
print()
print("range(len(x)) is the form to avoid unless you genuinely want the numbers.")

Output:

looping over the numbers, then looking each item up:
   0 apple
   1 pear
   2 plum

enumerate does both at once, and reads better:
   0 apple
   1 pear
   2 plum

and if you do not need the position at all:
   apple
   pear
   plum

range(len(x)) is the form to avoid unless you genuinely want the numbers.

enumerate() hands you the position and the item together, so there is no indexing and no chance of an off-by-one mistake.

If the position is irrelevant, loop over the items directly. Getting the index in a for loop covers enumerate and its start argument in full, and finding the index of an element handles the search case.

range can be reused

A small but useful property:

numbers = range(3)

print("a range can be looped more than once:")
print("  first pass  ->", list(numbers))
print("  second pass ->", list(numbers))
print()

generator = (x for x in range(3))
print("a generator cannot:")
print("  first pass  ->", list(generator))
print("  second pass ->", list(generator), " <- already used up")
print()
print("so range is safe to reuse, store, and pass around.")

Output:

a range can be looped more than once:
  first pass  -> [0, 1, 2]
  second pass -> [0, 1, 2]

a generator cannot:
  first pass  -> [0, 1, 2]
  second pass -> []  <- already used up

so range is safe to reuse, store, and pass around.

Looping over the same range twice works, because it regenerates the numbers each time. A generator expression is consumed on the first pass and comes back empty on the second.

for i in range quick reference

  • for i in range(n) runs n times, with i going from 0 to n - 1.
  • The stop value is never included; add one to reach it.
  • range() is not a list. Wrap it in list() only to look at the values.
  • range(start, stop, step) covers counting up, down and in steps.
  • for _ in range(n) signals that the counter is deliberately unused.
  • i keeps its last value after the loop, and assigning to it inside changes nothing.
  • Prefer enumerate() over range(len(items)).

Carry on with these:

Frequently asked questions

What does for i in range mean in Python?

It repeats the loop body once for each number range() produces, assigning that number to i. for i in range(5) runs five times with i as 0, 1, 2, 3, 4.

Why does range(5) stop at 4?

The stop value marks where counting ends and is not included. That makes range(len(items)) produce exactly the valid list positions. Add one to include the endpoint.

What does for _ in range mean?

The same as any other loop, but _ signals that the counter is deliberately unused. It is a normal variable name, not special syntax.

Is range a list in Python?

No. It is a range object that stores start, stop and step and produces values on demand, which is why it prints as range(0, 5). Use list(range(5)) to see the numbers.

What does i mean in for i in range?

It is just a variable name, short for index. Any valid name works, and a descriptive one such as attempt or row is usually clearer.

Why does range give a TypeError with a float?

range() only accepts whole numbers, and / always returns a float. Use // for integer division, as in range(n // 2).

Should I use range(len(my_list)) to loop over a list?

Usually not. Loop over the list directly, or use enumerate() when you need the position too. The built-in is described in the Python range documentation.