range() in Python: start, stop, step, and why it is lazy
range(1000000) uses 24 bytes. A list of a thousand numbers uses 4,028. Here is what range actually is, measured.
range() produces a sequence of whole numbers. It takes up to three arguments, and only the middle one is required:
range(stop)
range(start, stop)
range(start, stop, step)
list(range(5)) # [0, 1, 2, 3, 4]
list(range(2, 6)) # [2, 3, 4, 5]
list(range(0, 10, 3)) # [0, 3, 6, 9]
list(range(10, 0, -2)) # [10, 8, 6, 4, 2]
start is included and stop is excluded. That asymmetry is the source of nearly every mistake with range, and it exists for a reason covered below.
Every output on this page was run in Python 3.13 through Pyodide 0.28.3.
The three arguments
| Argument | Default | Meaning |
|---|---|---|
start |
0 |
First value produced |
stop |
required | Stops before this value |
step |
1 |
Amount added each time |
With one argument, it is stop, so range(5) counts from 0 up to 4. With two, they are start and stop. Only with three does the last one mean step.
The sequence is start, start + step, start + 2 * step, and so on, for as long as the value stays on the correct side of stop.
Why stop is excluded
This looks like an off-by-one waiting to happen, and it prevents more errors than it causes.
Because stop is excluded and start defaults to 0, range(n) produces exactly n values, which are exactly the valid indices of a list of length n:
nums = ["a", "b", "c"]
list(range(len(nums))) # [0, 1, 2]
len(nums) is 3, and the valid indices are 0, 1 and 2. If stop were included you would have to write range(len(nums) - 1) everywhere, and forgetting the - 1 would be an error rather than an inconvenience.
It also means adjacent ranges fit together with no gaps or overlaps. range(0, 5) and range(5, 10) cover 0 to 9 exactly once each.
Counting down needs a negative step
A common surprise: this produces nothing at all, and does not raise.
list(range(5, 1)) # []
The default step is +1, so the sequence starts at 5 and tries to climb toward 1, which it is already past. An empty range is a valid range, so you get an empty loop rather than an error. If a loop is silently not running, this is the first thing to check.
To count down, say so:
list(range(10, 0, -2)) # [10, 8, 6, 4, 2]
Note that 0 is excluded here too. To include zero, stop at -1.
range is not a list
This is the part most explanations mention in passing and it has real consequences.
r = range(1000000)
type(r).__name__ # 'range'
A range does not contain its numbers. It stores start, stop and step and computes each value on demand. Measured with sys.getsizeof:
| Object | Size |
|---|---|
range(1000000) |
24 bytes |
list(range(1000)) |
4,028 bytes |
A range covering a million numbers is smaller than a list of a thousand, because it never holds the numbers at all. That is why for i in range(10000000) starts instantly and uses no memory, while list(range(10000000)) would allocate the lot.
Only call list() on it when you genuinely need a list. Looping does not need one.
It is still a sequence
Being lazy does not make it a one-shot iterator. A range supports most of what a list supports, and this surprises people who expect generator behaviour:
r = range(1000000)
len(r) # 1000000
r[4] # 4
r[-1] # 999999
list(r[2:5]) # [2, 3, 4]
999999 in r # True
-1 in r # False
Indexing, negative indexing, slicing and len() all work, and slicing gives you back another range rather than a list.
The membership test is the one worth knowing about. 999999 in r is not a scan of a million values, it is arithmetic: Python checks whether the number falls in the interval and lands on the step. It is effectively instant regardless of the range's size, which is not true of in on a list.
Two ranges are also equal when they produce the same values, even if they were written differently:
range(3) == range(0, 3, 1) # True
The two errors it raises
range(1.5)
# TypeError: 'float' object cannot be interpreted as an integer
range is integers only. For a sequence of decimals you want a list comprehension dividing integers, or numpy.arange if you already have numpy. Building floats by repeated addition accumulates rounding error, which is the reason range declines to do it.
range(0, 5, 0)
# ValueError: range() arg 3 must not be zero
A step of zero would never reach stop, so Python refuses rather than looping forever.
The off-by-one table
Most range mistakes are one of these. Having them in one place is usually enough to stop making them:
| You want | Write | Not |
|---|---|---|
| 0 to 9 | range(10) |
range(9) |
| 1 to 10 | range(1, 11) |
range(1, 10) |
Every index of items |
range(len(items)) |
range(len(items) - 1) |
| 10 down to 1 | range(10, 0, -1) |
range(10, 0) |
| 10 down to 0 | range(10, -1, -1) |
range(10, 0, -1) |
| Every second item's index | range(0, len(items), 2) |
range(len(items) / 2) |
The last row also fails for a second reason: / produces a float, and range rejects floats. Integer division // is what you want if you really do need half the length.
The countdown rows are the ones people get wrong most, because the exclusive stop is easy to forget when the direction reverses. To reach zero going down, stop at -1. For walking a list backwards, reversed(range(len(items))) avoids the arithmetic; reversing a list compares it with the other ways.
Building things with range
Beyond loops, range is the source for a few common constructions:
squares = [n * n for n in range(5)] # [0, 1, 4, 9, 16]
evens = list(range(0, 20, 2)) # every even number under 20
grid = [[0] * 3 for _ in range(3)] # a 3x3 grid of zeros
That last one is worth reading carefully. The outer for _ in range(3) runs the comprehension three times, creating a new inner list each time. Writing [[0] * 3] * 3 instead would create one row and three references to it, so changing one row would change all three.
The underscore is a convention meaning "I do not use this value". It is a normal variable name, and using it signals to the reader that the loop is about repetition rather than about the numbers.
When you do not need range at all
The most common misuse is reaching for range to walk a list:
for i in range(len(items)):
print(items[i])
That works, and Python has a shorter way that cannot go out of bounds:
for item in items:
print(item)
If you need the position as well, enumerate gives you both, which is covered in how to loop with an index.
range(len(...)) earns its place in two situations: when you need to modify the list by index as you go, and when you are working with two lists of the same length by position. Even the second is usually better served by zip, covered in zip() and looping over two lists.
range, enumerate, or zip
Three tools that all appear in loop headers, and choosing between them is mostly mechanical:
| You need | Use |
|---|---|
| Just the items | for item in items |
| The items and their positions | for i, item in enumerate(items) |
| Two sequences together | for a, b in zip(xs, ys) |
| A count that is not tied to a list | for i in range(n) |
| To repeat something n times | for _ in range(n) |
range(len(items)) appears in none of them, which is the point. It is the answer to "I need positions", and enumerate answers that better by giving you the item as well, so you never index back into the list.
The last row is range's cleanest use: pure repetition, where the number is not an index at all. Retrying three times, generating five samples, printing a separator ten times.
Two things range is not
It is not a generator. Generators are consumed once; a range can be looped any number of times, and supports len, indexing and slicing. The similarity is only that neither stores its values.
It is not a loop. range(5) on its own does nothing, it just describes a sequence. This matters when reading code that stores one:
weekdays = range(0, 5)
That is a perfectly good value to pass around, test membership against, or loop over later. Treating range as a value rather than as loop syntax is what makes the indexing and slicing behaviour above feel natural rather than surprising.
Quick reference
range(5) # 0 1 2 3 4
range(1, 6) # 1 2 3 4 5
range(0, 10, 2) # 0 2 4 6 8
range(10, 0, -1) # 10 9 8 7 6 5 4 3 2 1
range(len(items)) # every valid index of items
list(range(3)) # [0, 1, 2] when you really need a list
Ready to use these in real loops? Start with the Python track, or read Python for loops for the construct range is almost always feeding.
More from the blog
What if __name__ == '__main__' actually does in Python
In Python, if __name__ == '__main__' runs code only when you run the file directly, not when it is imported. What __name__ holds and when you need it.
Read moreHow to reverse a list in Python (and a string)
To reverse a list in Python, use reverse() to change it in place, [::-1] for a reversed copy, or reversed() to loop backwards. Which to use and why.
Read moreReady to write some code?
Put this into practice - start your first free lesson. No setup, no credit card.