Range

range() produces an immutable, lazy sequence of integers — the standard way to drive a fixed number of loop iterations without ever building a list.

range() creates a range object: an immutable sequence of integers defined by a start, stop, and step, computed lazily on demand rather than stored as a materialized list. A range supports indexing, slicing, membership testing, and len(), just like a list or tuple, but takes constant memory regardless of how many integers it logically represents.

Creating a Range

range() accepts one, two, or three arguments: range(stop), range(start, stop), or range(start, stop, step). stop is always exclusive.

range(5)             # 0, 1, 2, 3, 4
range(2, 6)            # 2, 3, 4, 5
range(0, 10, 2)          # 0, 2, 4, 6, 8
range(10, 0, -2)           # 10, 8, 6, 4, 2 -- negative step counts down
list(range(5))               # [0, 1, 2, 3, 4] -- materialize when a list is needed

start, stop, step Semantics

start defaults to 0 and step defaults to 1. The sequence is generated as start, start+step, start+2*step, ... and stops before reaching or passing stop. A step of 0 is invalid.

r = range(1, 10, 3)
list(r)                # [1, 4, 7] -- stops before 10
range(5, 5)              # empty -- start == stop
range(5, 1)                # empty -- positive step can't go backward
range(1, 10, 0)              # ValueError: range() arg 3 must not be zero

Lazy, Constant-Memory Sequence

A range object does not store its elements; it stores only start, stop, and step, and computes each value on demand as it's accessed or iterated. This makes range(10**12) just as cheap to create as range(10), unlike building an equivalent list.

range's laziness is a genuine Python language guarantee dating back to Python 3 (in Python 2, range built a list eagerly and xrange was the lazy version); range is not a generator, though -- it's a full sequence type that supports indexing, slicing, len(), and repeated iteration, none of which a generator supports.

huge = range(10**12)
len(huge)          # 1000000000000 -- instant, no memory blow-up
huge[999999999999]   # 999999999999 -- direct O(1) computation, no scanning

Indexing and Slicing

Because range is a proper sequence (not just an iterable), it supports indexing (including negative indices) and slicing in O(1), computing the requested value(s) directly from start/stop/step rather than walking through the sequence.

r = range(0, 20, 2)
r[0]         # 0
r[-1]         # 18
r[2:5]         # range(4, 10, 2) -- a slice of a range is itself a range
list(r[2:5])    # [4, 6, 8]

Membership Testing

in on a range is O(1), not O(n): CPython computes membership arithmetically from start/stop/step rather than scanning, unlike membership testing on a list.

CPython implementation detail: the O(1) membership test is a specific optimization of range.__contains__; the language only guarantees range behaves as a correct sequence, not the exact algorithmic complexity of every operation.

r = range(0, 100, 5)
50 in r       # True -- computed directly
53 in r        # False

Negative Steps

A negative step counts downward; start must then be greater than stop for any values to be produced.

list(range(10, 0, -1))     # [10, 9, 8, ..., 1]
list(range(10, -1, -2))     # [10, 8, 6, 4, 2, 0]
list(range(0, 10, -1))       # [] -- direction mismatch produces nothing

Common Loop Usage

range() is the idiomatic way to loop a fixed number of times or to iterate by index; when the values themselves (not their positions) are what you need, prefer iterating the collection directly, and use enumerate() when you need both index and value together.

for i in range(5):
    print(i)              # 0..4 -- loop a fixed count

items = ["a", "b", "c"]
for i in range(len(items)):     # works, but usually not the best style
    print(i, items[i])

for i, item in enumerate(items):  # preferred -- clearer, same effect
    print(i, item)

Equality and Type

Two range objects are equal if they represent the same sequence of values, even if their start/stop/step differ in a way that produces identical output. range is its own type, distinct from list and tuple, though it converts to either easily.

range(0, 3) == range(0, 3, 1)    # True -- same values produced
range(0) == range(2, 2)             # True -- both are empty
type(range(5))                        # <class 'range'>
tuple(range(3))                         # (0, 1, 2)

Practical Usage

Use range() whenever you need to iterate a fixed number of times, generate arithmetic sequences of integers, or index into another sequence by position. Avoid materializing it into a list unless you actually need list operations (slicing assignment, mutation) — range already supports read-only sequence operations directly.

def sum_of_squares(n: int) -> int:
    return sum(i * i for i in range(n))

def every_third(items: list[str]) -> list[str]:
    return [items[i] for i in range(0, len(items), 3)]

Key Takeaways

range(stop), range(start, stop), and range(start, stop, step) generate an integer sequence with stop always exclusive.

range stores only its start/stop/step and computes values lazily, so its memory use doesn't grow with the sequence length.

It's a full sequence type (indexable, sliceable, has len()) — not a generator, and it can be iterated repeatedly.

Membership testing and indexing are computed arithmetically, making them fast regardless of range size.

A negative step requires start > stop; a mismatched direction produces an empty range.

for i in range(len(items)): print(items[i]) for item in items: print(item)

When only the values are needed, iterate the collection directly; range(len(...)) indirection is unnecessary and less readable.

nums = range(1000000) # then treated as if it were a list nums.append(1) # AttributeError nums = list(range(1000000)) nums.append(1) range objects are immutable and lack list methods; convert with list() first if you need to mutate the result.

range(1, 10, 0) # ValueError range(1, 10, 1) # or any nonzero step

A step of 0 is invalid because it would never make progress toward stop, so range() rejects it immediately.

Why does range(10**12) not run out of memory or take noticeably long to create?

A range object stores only its start, stop, and step and computes each element on demand; it never materializes the full sequence, so its memory footprint is constant regardless of how many values it logically represents.

Is range a generator? Justify your answer.

No. A generator is a one-shot, forward-only iterator with no len(), indexing, or slicing; range is a full sequence type that supports len(), indexing, negative indices, slicing (which returns another range), and can be iterated repeatedly from the start.

What does range(5, 5) produce, and why?

An empty range -- start equals stop, so no values satisfy 'less than stop while stepping from start', matching how an empty list or string behaves at its own boundary.

Why is 50 in range(0, 1000000, 5) fast even for a huge range?

CPython computes membership arithmetically from start/stop/step (checking divisibility and bounds) rather than iterating through every element, giving O(1) membership testing as a CPython implementation detail of range.__contains__.

What do you get from range(10, 0, -2), and what would range(0, 10, -2) give instead?

range(10, 0, -2) yields [10, 8, 6, 4, 2]; range(0, 10, -2) yields nothing (an empty range), because a negative step needs start greater than stop to produce any values, and here start is already less than stop.

How would you convert a range object into a list, and when would you actually need to?

Wrap it with list(range(...)); you'd need to when you require list-specific behavior like mutation, item assignment, or passing it to an API that specifically expects a list rather than any sequence/iterable.

Write a function that returns the sum of all multiples of 3 or 5 below a given limit, using range().

def sum_of_multiples(limit: int) -> int: return sum(n for n in range(limit) if n % 3 == 0 or n % 5 == 0) print(sum_of_multiples(20)) # 78 range(limit) lazily produces 0..limit-1, and the generator expression filters to multiples of 3 or 5 before summing, matching the classic Project Euler #1 pattern.

What does each line print?

len(r) is 6 (values 2,5,8,11,14,17). r[3] is 11 (the 4th element, 0-indexed). list(r[1:4]) is [5, 8, 11] -- slicing a range returns another range covering that sub-sequence, converted to a list here.

Write a function that reverses a list in place using only range() and indexing, without slicing or the built-in reverse().

def reverse_in_place(items: list[int]) -> None: n = len(items) for i in range(n // 2): items[i], items[n - 1 - i] = items[n - 1 - i], items[i] data = [1, 2, 3, 4, 5] reverse_in_place(data) print(data) # [5, 4, 3, 2, 1]

Iterating range(n // 2) visits only the first half of indices, swapping each with its mirror position (n-1-i); stopping at the midpoint avoids swapping every pair twice.

r = range(2, 20, 3)
print(len(r))
print(r[3])
print(list(r[1:4]))