range() in Python: The Complete Guide to Generating Number Sequences
If you have written more than ten lines of Python, you have already met `range()`. It is the function you reach for when you want to do something a fixed number of times, walk a list by index, or generate a clean sequence of integers without typing them all out. It looks trivial. It mostly is. But the one rule that trips up nearly every developer at some point is the same rule that makes `range()` compose so cleanly, and getting it wrong is the single most common off-by-one bug in the language.
This guide covers what `range()` actually does, all three of its forms, the exclusive-stop convention, counting down, why it is memory-efficient, and when you should reach for `enumerate()` instead.
Key Takeaways
• `range()` generates an immutable sequence of integers, most often used to drive a `for` loop.
• It has three forms: `range(stop)`, `range(start, stop)`, and `range(start, stop, step)`.
• The stop value is exclusive — `range(5)` yields `0, 1, 2, 3, 4`, never `5`.
• `range` is lazy: it returns a `range` object, not a list. It never builds the full sequence in memory.
• For an index *and* a value at the same time, use `enumerate()`, not `range(len(…))`.
What does range() do in Python?
The range Python built-in produces a sequence of evenly spaced integers. It does not return a list — it returns a `range` object that yields numbers on demand. The most common job for it is to repeat a block of code a set number of times.
“`python for i in range(5): print(i)
“`
That is the canonical pattern: `for i in range(n)`. The loop variable `i` takes each value the range produces, in order. Notice that it stopped at `4`, not `5`. Hold that thought — it is the whole game.
You can inspect a range directly, but printing one is anticlimactic:
“`python r = range(5) print(r) # range(0, 5) print(type(r)) #
The `range` object knows its bounds and step, and it computes each value only when you ask. We will come back to why that matters for memory.
What are the three forms of range()?
The python range function accepts one, two, or three arguments. Each form gives you more control over the sequence.
“`python
list(range(5)) # [0, 1, 2, 3, 4]
list(range(2, 7)) # [2, 3, 4, 5, 6]
list(range(0, 20, 5)) # [0, 5, 10, 15] “`
Here is how the arguments map to behaviour:
| Form | Start | Stop | Step | `list(…)` result |
|---|---|---|---|---|
| `range(5)` | 0 (default) | 5 | 1 (default) | `[0, 1, 2, 3, 4]` |
| `range(2, 7)` | 2 | 7 | 1 (default) | `[2, 3, 4, 5, 6]` |
| `range(0, 20, 5)` | 0 | 20 | 5 | `[0, 5, 10, 15]` |
| `range(10, 0, -1)` | 10 | 0 | -1 | `[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]` |
| `range(0, 10, 3)` | 0 | 10 | 3 | `[0, 3, 6, 9]` |
A few practical rules: all arguments must be integers, the step cannot be zero (`ValueError`), and if the start, stop, and step combine to produce nothing, you simply get an empty range.
“`python range(0, 5, 0) # ValueError: range() arg 3 must not be zero list(range(5, 5)) # [] (start equals stop -> empty) list(range(5, 0)) # [] (positive step can’t reach a lower stop) “`
Why does range stop before the stop value?
This is the rule that matters most, so it gets its own section. The stop value is exclusive. `range(5)` gives you five numbers: `0, 1, 2, 3, 4`. The number `5` is never produced. `range(1, 10)` gives you `1` through `9`, not `1` through `10`.
“`python list(range(1, 10)) # [1, 2, 3, 4, 5, 6, 7, 8, 9] — NOT up to 10 list(range(0, 3)) # [0, 1, 2] — NOT up to 3 “`
The single most common `range()` bug is the off-by-one error from forgetting that the stop value is exclusive — developers write `range(1, 10)` expecting `1` through `10` and get `1` through `9`. This is not arbitrary cruelty. The exclusive end is a deliberate design choice that makes `range` compose beautifully. Watch what falls out of it: `range(len(mylist))` gives you exactly the valid indices, `0` to `len – 1`, with no off-by-one correction needed. `range(0, n)` and `range(n, 2*n)` tile perfectly — back to back, no overlap, no gap. And `len(range(a, b))` is simply `b – a`, no `+1` anywhere. Once you internalize “range stops *before* the stop value,” the off-by-one errors vanish and the exclusive-end convention flips from gotcha to feature: it is precisely why ranges line up cleanly for indexing and chunking. The trick is to stop counting endpoints and start counting the gap — measure `stop – start`, not “from this to that.”
“`python mylist = [‘a’, ‘b’, ‘c’, ‘d’] list(range(len(mylist))) # [0, 1, 2, 3] — exactly the valid indices
list(range(0, 4)) # [0, 1, 2, 3] list(range(4, 8)) # [4, 5, 6, 7]
len(range(10, 25)) # 15 (just 25 – 10) “`
If you need to *include* the upper bound, add one to it deliberately: `range(1, 11)` to count `1` through `10`. Being explicit about the `+1` is cleaner than fighting the convention.
How do you count down with range()?
A negative step makes `range()` count backward. The catch: when stepping down, `start` must be *greater* than `stop`, and the stop is still exclusive.
“`python for i in range(10, 0, -1): print(i, end=’ ‘)
“`
That counts from `10` down to `1` — `0` is excluded, same rule as always. If you want to reach `0`, set the stop one past it:
“`python list(range(10, -1, -1)) # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1, 0] list(range(20, 0, -5)) # [20, 15, 10, 5] “`
A common alternative for reversing an existing range is `reversed()`, which reads more clearly than juggling negative steps:
“`python list(reversed(range(5))) # [4, 3, 2, 1, 0] “`
How do you use range() in a for loop?
This is the bread-and-butter use case. The classic `for i in range(n)` pattern shows up everywhere — running an operation N times, building lookup tables, driving counters. For a deeper treatment of loop mechanics, see the which pairs naturally with `range()`.
“`python
total = 0 for i in range(1, 101): # 1 through 100 total += i print(total) # 5050
squares = {} for n in range(1, 6): squares[n] = n ** 2 print(squares) # {1: 1, 2: 4, 3: 9, 4: 16, 5: 25} “`
When you genuinely need the position in a list, indexing through `range(len(…))` works:
“`python colors = [‘red’, ‘green’, ‘blue’] for i in range(len(colors)): print(i, colors[i])
“`
It works — but as the next section explains, it is usually the wrong tool.
How do you convert a range to a list?
A `range` object is iterable but is not a list. To materialize the actual numbers, wrap it in `list()` (or `tuple()`):
“`python nums = list(range(5)) print(nums) # [0, 1, 2, 3, 4] print(type(nums)) #
list(range(2, 11, 2)) # [2, 4, 6, 8, 10] tuple(range(3)) # (0, 1, 2) “`
You only need to do this when you actually want a concrete list — for slicing arbitrary positions, mutating elements, or passing to a function that expects a list. If you are just looping, do not convert; iterate the range directly and save the memory. For more on building and manipulating sequences, the reference covers the methods you will pair with this.
Why is range memory-efficient?
Here is the part that separates `range()` from a naive list of numbers. `range` is lazy. It stores only three integers — start, stop, and step — and computes each value on the fly as you iterate. It never builds the full sequence in memory.
“`python import sys
r = range(1_000_000) print(sys.getsizeof(r)) # 48 (bytes — constant!)
big_list = list(range(1_000_000)) print(sys.getsizeof(big_list)) # ~8000056 (bytes — eight million-plus) “`
A range over a million numbers costs the same handful of bytes as a range over five. Because it knows its own arithmetic, it supports `len()`, indexing, slicing, and membership tests without ever expanding:
“`python r = range(0, 100, 10) print(len(r)) # 10 print(r[3]) # 30 (indexes like a list) print(r[-1]) # 90 print(r[2:5]) # range(20, 50, 10) — slicing returns another range print(50 in r) # True (computed, not scanned) print(55 in r) # False “`
This is why `for i in range(10_000_000)` is perfectly fine — Python is not allocating ten million integers, it is counting.
Run your Python on a real environment you control. Lazy iteration is cheap, but real workloads — data pipelines, schedulers, web apps — need a dependable home. DarazHost VPS and dedicated servers give developers a real Python environment with full control: any version you need, your own packages, your scripts and apps running on guaranteed resources with root access. It is the dependable home your Python work needs, backed by 24/7 support. See the complete guide to a real development environment you control for how to set yours up.
When should you use enumerate instead of range?
Reach for `range(len(…))` and you will eventually write this:
“`python fruits = [‘apple’, ‘banana’, ‘cherry’] for i in range(len(fruits)): print(i, fruits[i]) “`
It works, but it is the unidiomatic way. When you need both the index and the value, `enumerate()` is cleaner, faster to read, and harder to get wrong:
“`python fruits = [‘apple’, ‘banana’, ‘cherry’] for i, fruit in enumerate(fruits): print(i, fruit)
“`
No `len()`, no manual indexing, no chance of an index error. You can even set a starting count:
“`python for i, fruit in enumerate(fruits, start=1): print(i, fruit)
“`
The rule of thumb: if you only need to repeat or count, use `range()`. If you are iterating a sequence and need the index alongside the item, use `enumerate()`. The guide goes deeper on this pattern. Keep `range(len(…))` for the rare case where you need indices without the values — for example, iterating two lists in lockstep by position.
Here is a quick decision summary:
| You want to… | Use |
|---|---|
| Repeat something N times | `range(n)` |
| Generate a numeric sequence | `range(start, stop, step)` |
| Count down | `range(start, stop, -step)` |
| Index *and* value together | `enumerate(seq)` |
| Just the values of a list | `for item in seq` (no range at all) |
What are common range() patterns?
A handful of patterns cover most real usage. New to the language? The sets the groundwork these build on.
“`python
for _ in range(3): print(“retry”)
data = [‘a’, ‘b’, ‘c’, ‘d’, ‘e’, ‘f’] for i in range(0, len(data), 2): print(data[i], end=’ ‘) # a c e
items = list(range(1, 11)) size = 3 chunks = [items[i:i + size] for i in range(0, len(items), size)] print(chunks) # [[1, 2, 3], [4, 5, 6], [7, 8, 9], [10]]
for n in range(1, 4): row = [n * m for m in range(1, 6)] print(row)
“`
Pattern 3 — chunking — is where the exclusive-stop convention pays off most: `range(0, len(items), size)` produces exactly the right starting indices, and the slice `items[i:i + size]` clips itself at the end automatically. No special-casing the last chunk.
Frequently asked questions
Does range() include the stop value? No. The stop value is always exclusive. `range(5)` produces `0, 1, 2, 3, 4`, and `range(1, 10)` produces `1` through `9`. To include an upper bound, add one to it: `range(1, 11)` counts `1` through `10`.
What is the difference between range() in Python 2 and Python 3? In Python 3, `range()` returns a lazy `range` object. In Python 2, `range()` built a full list immediately and `xrange()` was the lazy version. In modern Python (3.x), there is only `range()`, and it is lazy. If you are reading old code that uses `xrange()`, replace it with `range()`.
Can range() handle floats or decimals? No. `range()` accepts only integers and raises a `TypeError` on floats. For a sequence of floating-point numbers, use a library like NumPy (`numpy.arange(0, 1, 0.1)`) or build it with a comprehension: `[x * 0.1 for x in range(10)]`.
Why use range(len(my_list)) at all if enumerate exists? Mostly you should not. `enumerate()` is the idiomatic choice when you need index and value together. `range(len(…))` is justified only when you need indices alone — for instance, stepping through two parallel lists by position, or when you need to mutate elements in place by index.
Is a range object the same as a list? No. A `range` is a lazy, immutable sequence that computes values on demand and stores only its start, stop, and step. A list holds every element in memory. A range supports indexing, slicing, `len()`, and membership tests, but you cannot append to it or change its contents. Convert with `list(range(…))` when you genuinely need a mutable list.