Devpuff
Pricing
Log InStart Free
  1. Home
  2. Blog
  3. range() in Python: start, stop, step, and why it is lazy
Learn

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.

By Max Arthur
Co-Founder & Content Marketer·October 12, 2026·8 Min read
White text reading 'How range() works in Python' on a dark purple dotted background

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.

Keep reading

More from the blog

White text reading 'What if name equals main does in Python' on a dark purple dotted background
October 27, 2026·8 min readLearn

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 more
White text reading 'How to reverse a list in Python' on a dark purple dotted background
October 26, 2026·8 min readLearn

How 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 more
{ }
✦

Ready to write some code?

Put this into practice - start your first free lesson. No setup, no credit card.

Start learning free
Devpuff

Learn to code by doing. One tiny, playful lesson at a time.

Learn
ProgramsCoursesPricing
Company
AboutBlogResourcesAffiliates
Support
Help CenterContactStatus
Programs
Frontend DevelopmentFull-Stack DevelopmentReact DeveloperPython Developer
Courses
Advanced ReactAsync JavaScriptAsync PythonCSS BasicsCSS LayoutDSA Basics
Learn to Code
Learn JavaScriptLearn PythonLearn SQLLearn HTMLLearn CSSLearn ReactBrowse All Topics
Platform Comparisons
Devpuff vs CodecademyDevpuff vs Mimo
© 2026 Devpuff. All rights reserved.Privacy PolicyTerms and ConditionsCookies PolicyRefund Policy
Devpuff vs Sololearn
Devpuff vs freeCodeCamp
Read All Comparisons