If you have spent any time writing Python, you have almost certainly typed range() without giving it much thought. It shows up in every beginner tutorial, every loop example, every "how do I iterate ten times?" Stack Overflow answer. But there is a lot more going on under the hood than those simple examples suggest, and understanding it thoroughly will make you a significantly better Python programmer. This guide goes beyond the basics into the design philosophy, internal mechanics, hidden capabilities, and practical patterns that separate someone who uses range() from someone who truly understands it.
What range() Actually Is (And What It Is Not)
Here is something that trips up a lot of beginners and even some intermediate programmers: range() does not return a list. Technically, it is not even a function. As the Python documentation states, range is a type — a class. When you call range(10), you are invoking the constructor of the range class to create a new range object.
This distinction was formalized in Python 3. In Python 2, range() returned an actual list and xrange() returned a lazy iterable. When Python 3 was designed, the core developers decided that xrange() should become the default behavior for range(), and the old list-returning version was removed entirely. As Trey Hunner documented in his analysis of the transition, Python 3's range gained several capabilities that its predecessor lacked entirely (Source: treyhunner.com).
You can verify this yourself in any Python 3 interpreter:
>>> type(range(10))
<class 'range'>
This matters enormously in performance terms. If you call range(1000000), Python does not allocate memory for a million integers. It stores three numbers: the start, the stop, and the step. The memory footprint of range(10) and range(10000000) is essentially identical. You can verify this with sys.getsizeof().
You can prove the memory efficiency claim directly:
import sys
print(sys.getsizeof(range(10))) # 48 bytes
print(sys.getsizeof(range(10_000_000))) # 48 bytes
print(sys.getsizeof(list(range(10)))) # 136 bytes
print(sys.getsizeof(list(range(10_000_000)))) # 80,000,056 bytes
That said, a range object still supports many of the same operations as a list. You can index into it, slice it, check membership with in, and get its length with len(). According to the Python documentation, range objects implement the collections.abc.Sequence abstract base class, providing features like containment tests, element index lookup, slicing, and support for negative indices (Source: Python Docs, Built-in Types).
>>> r = range(0, 100, 5)
>>> r[3]
15
>>> len(r)
20
>>> 50 in r
True
>>> 51 in r
False
Membership testing with in on a range object is O(1) — Python computes whether a value falls within the range mathematically rather than iterating through every element. This makes range objects useful for bounds checking in performance-sensitive code. In Python 2's xrange, this optimization did not exist — membership testing required iterating through every element sequentially.
Why the Stop Value Is Excluded: The Design Philosophy
Many beginners find it counterintuitive that range(1, 10) produces 1 through 9 rather than 1 through 10. This is not arbitrary — it is rooted in a deliberate mathematical design choice with a long history in computer science.
In 1982, Edsger Dijkstra wrote a short paper titled "Why numbering should start at zero" (EWD 831) in which he argued that half-open intervals — where the lower bound is included and the upper bound is excluded — are the cleanest way to represent sequences. Dijkstra demonstrated that this convention uniquely satisfies several practical properties at once (Source: University of Texas Dijkstra Archive).
Dijkstra observed that half-open intervals let the difference of the bounds equal the sequence length, and that adjacent subsequences share boundary values cleanly. — Paraphrased from EWD 831
Python's range() inherits this design directly. The half-open interval convention gives you three concrete advantages that become apparent once you know to look for them:
1. The length equals the difference of the bounds. The number of elements in range(a, b) is always b - a. No plus-one, no minus-one. Just subtraction.
>>> len(range(3, 8))
5
>>> 8 - 3
5
2. Adjacent ranges tile perfectly. If you split a range at any point, the end of one equals the start of the next. There are no gaps and no overlaps:
>>> list(range(0, 5)) + list(range(5, 10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Identical to:
>>> list(range(0, 10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
3. Empty ranges are natural. When start equals stop, you get an empty sequence without needing special syntax. With inclusive bounds, representing an empty range would require awkward constructions like [a, a-1].
>>> list(range(5, 5))
[]
This same convention is used consistently throughout Python — in string slicing, list slicing, and everywhere else that index boundaries appear. Once you internalize it, an entire category of off-by-one errors disappears from your code.
The Three Ways to Call range()
The range() constructor accepts one, two, or three arguments. Each calling pattern serves a different purpose.
range(stop)
The simplest form takes a single integer as the stop value. The sequence begins at 0 and increments by 1, stopping before the given value.
for i in range(5):
print(i)
Output:
0
1
2
3
4
The end point is never included in the sequence. range(5) gives you five values: 0, 1, 2, 3, and 4. This is intentional and consistent with Python's broader indexing convention — if a list has five elements, their indices are 0 through 4. Using range(len(my_list)) gives you exactly the valid index positions.
range(start, stop)
The two-argument form lets you specify where the sequence begins.
for i in range(3, 8):
print(i)
Output:
3
4
5
6
7
Again, the stop value is exclusive. range(3, 8) gives you five values starting at 3 and ending at 7. This form is useful when you are working with a slice of a larger dataset, iterating through a specific range of line numbers, or building sequences that do not start at zero.
range(start, stop, step)
The three-argument form adds a step value — the increment between each number in the sequence.
for i in range(0, 20, 4):
print(i)
Output:
0
4
8
12
16
The step can be any non-zero integer, including negative values. A negative step lets you count backward:
for i in range(10, 0, -1):
print(i)
Output:
10
9
8
7
6
5
4
3
2
1
When using a negative step, the logic reverses: the start value must be greater than the stop value, or the range will be empty. range(0, 10, -1) produces nothing at all. A step of zero raises a ValueError unconditionally.
Working with range() in Practice
Iterating a Fixed Number of Times
The simplest use case is just running a block of code a specific number of times when you do not actually need the index value:
for _ in range(5):
print("Hello from Python")
The underscore is a Python convention for a variable you are intentionally ignoring. This pattern is common in retry logic, test data generation, and simulation loops.
Using the Index in Loops
When you need the current index while iterating over a sequence, range(len()) works but is generally not the preferred approach:
fruits = ["apple", "banana", "cherry"]
for i in range(len(fruits)):
print(f"{i}: {fruits[i]}")
Python's enumerate() is usually cleaner for this specific scenario:
for i, fruit in enumerate(fruits):
print(f"{i}: {fruit}")
That said, there are cases where range(len()) is the right tool — particularly when you need to access multiple elements by index simultaneously (such as comparing adjacent items), when you are modifying the list in place, or when you need to iterate over indices in a non-standard order.
Building Number Sequences
Range objects can be converted to lists or other sequence types when you actually need a materialized collection:
even_numbers = list(range(0, 20, 2))
# [0, 2, 4, 6, 8, 10, 12, 14, 16, 18]
odd_numbers = list(range(1, 20, 2))
# [1, 3, 5, 7, 9, 11, 13, 15, 17, 19]
countdown = list(range(10, 0, -1))
# [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
List Comprehensions with range()
Range objects pair naturally with list comprehensions to build computed sequences:
squares = [x**2 for x in range(1, 11)]
# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
multiples_of_three = [x for x in range(1, 50) if x % 3 == 0]
# [3, 6, 9, 12, 15, 18, 21, 24, 27, 30, 33, 36, 39, 42, 45, 48]
Nested Loops and Grids
Two nested range calls are a natural way to iterate over a two-dimensional grid:
rows = 4
cols = 5
for row in range(rows):
for col in range(cols):
print(f"({row},{col})", end=" ")
print()
This pattern comes up constantly in image processing, game development, matrix operations, and any application that works with grid-based data.
Hidden Capabilities You Probably Don't Know About
Many Python programmers treat range() as a simple loop counter and never discover its more powerful features. Here are capabilities that rarely show up in tutorials but are fully supported and documented.
The start, stop, and step Attributes
Every range object exposes its construction parameters as read-only attributes. This is useful when you receive a range as a function argument and need to inspect its boundaries:
r = range(5, 50, 3)
print(r.start) # 5
print(r.stop) # 50
print(r.step) # 3
These attributes enable you to write functions that dynamically adapt based on the range they receive — for instance, splitting a range into sub-ranges for parallel processing without needing to pass start, stop, and step as separate parameters.
Slicing Returns a New Range
When you slice a range object, the result is another range object — not a list. Python computes the new start, stop, and step arithmetically rather than materializing any values:
>>> r = range(0, 100, 5)
>>> r[2:6]
range(10, 30, 5)
>>> r[::-1]
range(95, -5, -5)
This means you can slice enormous ranges without consuming any memory. A slice of range(10**18) is still just three stored integers.
Range Objects Are Hashable
Because range objects are immutable, they are hashable — which means you can use them as dictionary keys or store them in sets. This is unusual for a sequence type (lists, for example, are not hashable) and it opens up some interesting patterns:
>>> hash(range(10))
5765085853580824917
>>> d = {range(0, 100): "first hundred", range(100, 200): "second hundred"}
>>> d[range(0, 100)]
'first hundred'
This is a niche feature, but it can be useful in dispatch tables or caching scenarios where you need to associate behavior with specific numeric intervals.
Value-Based Equality Since Python 3.3
Since Python 3.3, two range objects are considered equal if they produce the same sequence of values, even when their start, stop, and step attributes differ. The Python documentation confirms that equality compares them as sequences (Source: Python Docs, Built-in Types):
>>> range(0, 10, 3) == range(0, 11, 3) # Both produce [0, 3, 6, 9]
True
>>> range(0) == range(2, 1, 3) # Both are empty
True
>>> range(0, 3, 2) == range(0, 4, 2) # Both produce [0, 2]
True
This matters when using ranges as dictionary keys or in sets — two differently-constructed ranges that produce the same values will be treated as the same key.
Concatenating Ranges with itertools.chain()
Range objects do not support the + operator for concatenation. If you try range(5) + range(5, 10), you will get a TypeError. The solution is itertools.chain(), which lets you combine multiple ranges (or any iterables) into a single lazy sequence without materializing anything into a list:
from itertools import chain
combined = chain(range(0, 10), range(20, 30), range(40, 50))
for num in combined:
print(num, end=" ")
# 0 1 2 3 4 5 6 7 8 9 20 21 ... 40 41 ... 49
This is useful when you need to iterate over non-contiguous intervals without creating a single massive list — for example, when processing specific page ranges from a document, or iterating over allowed port numbers that span multiple ranges.
Reversing with reversed()
Range objects support reversed() natively, producing an efficient reverse iterator without creating a list:
for i in reversed(range(10)):
print(i, end=" ")
# 9 8 7 6 5 4 3 2 1 0
This is typically cleaner than constructing a negative-step range manually, and it works with any range regardless of its original step value.
Arbitrarily Large Ranges
Because Python 3 integers have no fixed size limit, you can create ranges with astronomically large bounds. This was not possible with Python 2's xrange(), which was limited to C long integers:
>>> r = range(10**18)
>>> len(r)
1000000000000000000
>>> 42 in r
True
The range object stores only three integers regardless of the magnitude. Memory usage does not change. Note that ranges with values exceeding sys.maxsize are permitted, but len() will raise an OverflowError for those extreme cases. Membership testing and iteration still work normally.
Real Application Examples
Batch Processing with Chunk Offsets
APIs often limit how many records you can fetch in a single request. Range with a step lets you calculate batch offsets cleanly:
total_records = 10000
batch_size = 250
for offset in range(0, total_records, batch_size):
end = min(offset + batch_size, total_records)
print(f"Fetching records {offset} to {end}")
# fetch_records(offset, batch_size)
This produces clean, non-overlapping batches without any manual arithmetic. The half-open interval design makes this work seamlessly — each batch's stop is the next batch's start.
Sliding Window Analysis
When analyzing sequential data — network traffic logs, stock prices, or sensor readings — you often need to examine a "window" that slides through the dataset. Range makes this straightforward:
def sliding_window_average(data, window_size):
averages = []
for i in range(len(data) - window_size + 1):
window = data[i:i + window_size]
averages.append(round(sum(window) / window_size, 2))
return averages
temperatures = [68, 71, 73, 70, 69, 72, 75, 74, 71, 68]
print(sliding_window_average(temperatures, 3))
# [70.67, 71.33, 70.67, 70.33, 72.0, 73.67, 73.33, 71.0]
Timing and Rate Limiting
In scripts that hit external services, you sometimes need to process items in timed waves:
import time
requests_per_second = 5
total_requests = 50
for i in range(total_requests):
# process_request(i)
if (i + 1) % requests_per_second == 0:
time.sleep(1)
Generating ASCII Art and Patterns
Range makes it straightforward to build symmetrical patterns programmatically:
size = 7
# Triangle
for i in range(1, size + 1):
print("*" * i)
# Centered diamond
for i in range(1, size + 1, 2):
spaces = (size - i) // 2
print(" " * spaces + "*" * i)
for i in range(size - 2, 0, -2):
spaces = (size - i) // 2
print(" " * spaces + "*" * i)
Simulation and Monte Carlo Methods
Scientific simulations frequently need to run an experiment thousands or millions of times:
import random
trials = 100000
inside_circle = 0
for _ in range(trials):
x = random.uniform(-1, 1)
y = random.uniform(-1, 1)
if x**2 + y**2 <= 1:
inside_circle += 1
pi_estimate = 4 * inside_circle / trials
print(f"Estimated pi: {pi_estimate:.4f}")
This Monte Carlo estimation of pi demonstrates how range() enables straightforward large-scale iteration without any concern for memory overhead.
Working with Database Pagination
When processing large database result sets, offset-based pagination is a common pattern:
PAGE_SIZE = 100
total_rows = get_row_count() # hypothetical function
for page_start in range(0, total_rows, PAGE_SIZE):
rows = fetch_rows(offset=page_start, limit=PAGE_SIZE)
for row in rows:
process_row(row)
Building Lookup Tables
Sometimes you need a precomputed table of values for performance-critical code:
# Precompute Fahrenheit to Celsius conversion for integer temperatures
fahrenheit_range = range(-40, 213) # -40F to 212F (water boiling point)
conversion_table = {f: round((f - 32) * 5 / 9, 2) for f in fahrenheit_range}
print(conversion_table[32]) # 0.0
print(conversion_table[212]) # 100.0
print(conversion_table[98]) # 36.67 (approximate body temperature)
Port Scanning and Network Validation
In security and network administration, range is a natural fit for working with port numbers and IP address components:
# Validate that a port number is in the registered range
registered_ports = range(1024, 49152)
user_port = 8080
if user_port in registered_ports:
print(f"Port {user_port} is in the registered range")
# Check multiple port ranges efficiently
from itertools import chain
well_known = range(0, 1024)
registered = range(1024, 49152)
dynamic = range(49152, 65536)
all_ports = chain(well_known, registered, dynamic)
# Iterate without creating a list of 65,536 integers
Binary Search Implementation
Range is useful in manual implementations of algorithms that work by narrowing an index boundary:
def binary_search(sorted_list, target):
low = 0
high = len(sorted_list) - 1
while low <= high:
mid = (low + high) // 2
if sorted_list[mid] == target:
return mid
elif sorted_list[mid] < target:
low = mid + 1
else:
high = mid - 1
return -1
data = list(range(0, 1000, 5)) # [0, 5, 10, 15, ..., 995]
print(binary_search(data, 375)) # 75
Common Mistakes and How to Avoid Them
Mistake 1: Expecting the Stop Value to Be Included
This is the single most common range() mistake. If you want the numbers 1 through 10, you need range(1, 11), not range(1, 10).
# Wrong - only goes to 9
for i in range(1, 10):
print(i)
# Correct - goes to 10
for i in range(1, 11):
print(i)
Understanding the Dijkstra rationale discussed earlier helps this feel less arbitrary: the length of a range always equals stop minus start, and adjacent ranges always tile without gaps.
Mistake 2: Using range() on Non-Integer Arguments
Range only accepts integers. Passing floats will raise a TypeError:
>>> range(0, 1, 0.1)
TypeError: 'float' object cannot be interpreted as an integer
If you need a float range, there are several approaches depending on your needs. For quick scripts, a list comprehension works well. For scientific computing, numpy.arange() or numpy.linspace() are the standard solutions — and linspace is often preferred because it avoids floating-point accumulation errors that can plague arange:
import numpy as np
# numpy.arange - watch for float precision issues
float_range = np.arange(0.0, 1.0, 0.1)
# array([0. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9])
# numpy.linspace - specify number of points instead of step
precise_range = np.linspace(0.0, 1.0, 11)
# array([0. , 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1. ])
# Without NumPy:
steps = 10
float_range = [i / steps for i in range(steps + 1)]
# [0.0, 0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0]
The Python documentation itself contains a linspace recipe showing how to build a lazy float-range class (Source: Python Docs, Built-in Types).
Mistake 3: Forgetting the Step Direction
When counting down, both the step and the start/stop relationship must be consistent:
# This produces an empty range - wrong direction
list(range(10, 0, 1)) # []
# Correct countdown
list(range(10, 0, -1)) # [10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
The mental model: the step must move the current value from start toward stop. If the step moves it away from stop, the range is empty.
Mistake 4: Treating range() Like a List When It Matters
In many contexts you can use a range object and a list interchangeably, but not all. Concatenation, for example, works on lists but not range objects:
>>> range(5) + range(5, 10)
TypeError: unsupported operand type(s) for +: 'range' and 'range'
>>> list(range(5)) + list(range(5, 10))
[0, 1, 2, 3, 4, 5, 6, 7, 8, 9]
# Or use itertools.chain for lazy concatenation:
from itertools import chain
combined = chain(range(5), range(5, 10))
Mistake 5: Using range() to Modify a List While Iterating
Modifying a list while iterating over its indices can produce subtle bugs:
data = [1, 2, 3, 4, 5, 6]
# Dangerous - removing items shifts indices
for i in range(len(data)):
if data[i] % 2 == 0:
del data[i] # IndexError likely
When filtering a list, list comprehensions or the filter() function are safer. If you must delete in place, iterate in reverse to avoid index shifting:
# Safe: list comprehension (creates new list)
data = [x for x in data if x % 2 != 0]
# Safe: reverse iteration for in-place deletion
for i in range(len(data) - 1, -1, -1):
if data[i] % 2 == 0:
del data[i]
Mistake 6: Confusing range() with an Iterator
A range object is an iterable, not an iterator. The distinction matters: an iterator is consumed after one pass, while an iterable can produce new iterators repeatedly. You can loop over the same range object multiple times:
r = range(5)
# First pass
for i in r:
print(i, end=" ") # 0 1 2 3 4
# Second pass - works fine, unlike a consumed iterator
for i in r:
print(i, end=" ") # 0 1 2 3 4
This is why you can safely pass a range to multiple functions or use it in multiple loops without recreating it.
Performance Considerations
For routine code, performance differences between range() approaches are negligible. But in performance-critical inner loops, a few things are worth knowing.
A range object's __contains__ method (membership testing with in) runs in O(1) time because it uses arithmetic rather than iteration. This makes range a good tool for fast bounds checking:
valid_ports = range(1024, 65536)
user_input = 8080
if user_input in valid_ports:
print("Valid port")
Compare this to using a list for the same check. The list version would require O(n) time and would consume roughly 500 KB of memory just to store the integers. The range object uses 48 bytes and answers in constant time.
If you convert a range to a list before iterating, you lose the O(1) membership advantage and add unnecessary memory allocation. Iterate directly over the range object whenever possible.
Indexing into a range is also O(1). When you write range(0, 1000000, 7)[500], Python does not generate 501 values — it computes the answer with start + index * step.
For very tight loops where you are calling range() billions of times in a numerical computation, libraries like NumPy provide vectorized operations that will outperform any Python loop regardless of how range() is used. At that scale, the solution is not to optimize the range call — it is to eliminate the Python-level loop entirely with array operations.
Comparing range() to Related Tools
Python offers several related iteration tools. Understanding when to use each one leads to cleaner code.
enumerate()is the right choice when you need both the index and the value from an existing iterable. Prefer it overrange(len(sequence))for readability.zip()is the right choice when you need to iterate over two sequences in parallel by position.itertools.count()gives you an infinite counter when you do not know in advance how many steps you need. Think of it as an unbounded range.itertools.chain()lets you combine multiple ranges (or any iterables) into a single lazy sequence, solving the concatenation limitation.numpy.arange()handles float ranges and large numerical sequences where vectorized math matters.numpy.linspace()is preferred overarangewhen you want a specific number of evenly-spaced points between two values, avoiding floating-point drift.range()remains the right choice when you need a simple sequence of integers, whether for controlling loop repetition, computing index offsets, generating test data, or anything else that maps naturally to a sequence of whole numbers.
Summary
The range() type is one of Python's genuinely foundational tools, and it is far more capable than its simple appearance suggests. It returns a lazy, immutable, hashable sequence object with O(1) membership testing and O(1) indexing, supports positive and negative steps, works seamlessly with list comprehensions and other iteration tools, and scales to arbitrarily large ranges without memory concerns.
Its design reflects a deliberate mathematical choice — the half-open interval convention that Dijkstra championed in 1982 — which eliminates entire categories of off-by-one errors once you internalize it. The three-argument form gives you precise control over where a sequence begins, ends, and how quickly it moves. And because range objects are true sequence types, they support indexing, slicing (which returns new range objects rather than lists), length queries, equality comparison by value, and even use as dictionary keys.
Whether you are paging through database records, validating network ports, running simulations, building lookup tables, or just repeating a task a fixed number of times, range() is the right tool to reach for. Understanding it thoroughly means one less thing to look up and one more tool you can use confidently and correctly.
Guido van Rossum has said that the joy of coding Python should be in seeing short, concise, readable classes that express a lot of action in a small amount of clear code. — Paraphrased from public statements by Python's creator
range() embodies that philosophy perfectly: a small, clean interface with powerful semantics underneath.