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.

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'

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

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]
| Call | Produces | Reads as |
|---|---|---|
range(5) | 0 1 2 3 4 | Five numbers from zero |
range(2, 6) | 2 3 4 5 | From 2, stopping before 6 |
range(0, 10, 2) | 0 2 4 6 8 | From 0 to 10, every second |
range(10, 0, -1) | 10 9 8 ... 1 | Counting down |
range(5, 5) | nothing | Start 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

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

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]

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)runsntimes, withigoing from 0 ton - 1.- The stop value is never included; add one to reach it.
range()is not a list. Wrap it inlist()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.ikeeps its last value after the loop, and assigning to it inside changes nothing.- Prefer
enumerate()overrange(len(items)).
Carry on with these:
- Get the index in a Python for loop
- List comprehension with if and else
- Find the index of an element in a list
- Iterate through a dictionary
- Split a list in Python
- Reverse a list in Python
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.

Bijay Kumar is a 13-time Microsoft MVP with more than 18 years in software development, and the founder of Python Guides and TSinfo Technologies. He started out building .NET and SharePoint solutions at HP, TCS and KPIT before moving into Python, machine learning and AI, and he also builds web apps with TypeScript and React. He writes the tutorials here himself, and every example is run before publishing so you see the real output. More about Bijay · Microsoft MVP profile · LinkedIn