Python's xrange(): The Full Story Behind the Function That Shaped range()

If you have ever hit a NameError: name 'xrange' is not defined while running old Python code, you are not alone. It is one of the recurring stumbling blocks when migrating Python 2 code to Python 3, and it still surfaces in 2026 when developers encounter legacy codebases, outdated tutorials, or copy-pasted Stack Overflow answers from the Python 2 era. But the story behind xrange() is far richer than a simple deprecation notice. It is a story about lazy evaluation, language philosophy, memory efficiency, design debt, and one of the most deliberate engineering cleanups in Python's history.

This article goes deeper than the typical "just replace it with range()" advice. We trace the origins of xrange(), understand exactly how it worked under the hood, examine the PEPs (Python Enhancement Proposals) that shaped its evolution and eventual removal, explore why Python 3's range() is actually a more powerful successor than many developers realize, and investigate why this single design change reflects a broader transformation in how Python thinks about computation itself.

The Problem xrange() Was Built to Solve

In Python 2, the range() function returned a fully materialized list. Call range(10), and Python would allocate a list object in memory containing every integer from 0 through 9. For small ranges, this was perfectly fine. But consider what happens when you write something like this in Python 2:

# Python 2
for i in range(100000000):
    pass

Before the loop even begins, Python constructs a list of 100 million integers. On a 64-bit system, each integer object in CPython 2 consumes at least 24 bytes on a 64-bit system (the PyIntObject struct includes a reference count, type pointer, and the integer value). That single range() call could easily consume several gigabytes of memory, and the program might crash with a MemoryError before it executes a single iteration of the loop.

This is a textbook case of paying an enormous cost for something you do not need. The loop only needs one integer at a time. Building the entire list upfront is like printing every page of a dictionary before you look up a single word.

This was the problem xrange() was designed to solve. Instead of building the entire list upfront, xrange() returned an xrange object that generated each integer on demand during iteration, a technique known as lazy evaluation. The object itself occupied a fixed, tiny amount of memory regardless of the range size:

# Python 2
import sys

print(sys.getsizeof(range(1000000)))   # Millions of bytes
print(sys.getsizeof(xrange(1000000)))  # Around 40 bytes

The memory footprint of xrange(1000000) was essentially the same as xrange(10). It stored only three integers internally (start, stop, and step) and computed each value as the iterator advanced. The conceptual insight here is important: xrange did not store a sequence. It stored a formula for generating one.

How xrange() Actually Worked

The xrange object was not a generator in the technical sense. Generators in Python are created with yield statements, and they are single-pass iterators: once consumed, they are exhausted. The xrange object was different. It was a reusable sequence-like object. You could iterate over the same xrange object multiple times, and you could index into it directly:

# Python 2
x = xrange(10)
print(x[3])   # 3
print(x[-1])  # 9
print(len(x)) # 10

for i in x:
    print(i)   # Works the first time

for i in x:
    print(i)   # Works again, not exhausted

This distinction matters because it means xrange occupied a conceptual space between a generator (single-pass, lazy) and a list (multi-pass, eager). It gave you the memory efficiency of laziness with the reusability of a concrete collection. Understanding this hybrid nature is key to understanding why Python 3's range could inherit the role so cleanly.

Internally, the CPython implementation lived in Objects/rangeobject.c. The xrange object stored its start, stop, and step values as C long integers and computed individual elements on the fly using simple arithmetic: element = start + (index * step). There was no list, no allocation per element during object creation, and no yielding. Just math.

Hard Limit

xrange could only handle values that fit in a C long. This was a direct consequence of its C-level implementation: the struct stored start, stop, and step as long fields, not as Python objects. Attempting to create an extremely large range would fail with OverflowError: Python int too large to convert to C long. Python 3's range uses Python's arbitrary-precision integers internally, so range(10**19) works without issue. This is one of the concrete ways Python 3's version is not merely a rename.

The xrange Object's Sequence Identity Crisis

Early in its life, the xrange object tried to behave like a full sequence type. It supported slicing (x[2:5]), repetition (x * 3), comparisons (cmp(x1, x2)), containment testing (5 in x), a .tolist() method, and .start, .stop, and .step attributes. In theory, this made xrange a drop-in replacement for range() in any context, not just loops.

In practice, these features were barely used. And because they were barely used, they were barely tested. Bugs accumulated silently. In PEP 260, authored June 26, 2001, Guido van Rossum argued that xrange's rarely-used sequence behaviors had accumulated serious bugs, including off-by-one errors, that went undetected across multiple releases (source: PEP 260).

PEP 260, titled "Simplify xrange()," proposed stripping xrange down to its bare minimum. The only retained behaviors would be indexing (x[i]), len(x), and repr(x). Everything else would go: slicing, repetition, comparison, containment testing, .tolist(), and the start/stop/step attributes.

This PEP was accepted and implemented in Python 2.2 (released December 2001). The stripped features triggered deprecation warnings in 2.2 and were fully removed by Python 2.4. Van Rossum's rationale was direct: if the bugs in these features went undetected for multiple releases, essentially nobody was relying on them. Keeping buggy, untested code around was a maintenance burden with no upside.

Design Lesson

PEP 260 illustrates a principle that applies far beyond Python: features that nobody tests are features that nobody needs. The maintenance cost of untested code is not zero; it is negative. It actively degrades the reliability of the codebase by creating a false sense of completeness. Van Rossum's willingness to strip features from a widely-used built-in type is a rare example of a language designer choosing correctness over backward compatibility, and it foreshadowed the much larger cleanup that Python 3 would undertake.

The Philosophical Principle: One Obvious Way

To understand why xrange() was eventually removed entirely, you need to understand one of Python's core design principles. PEP 20, "The Zen of Python" (authored by Tim Peters, included in every Python installation via import this), contains the well-known line stating that there should be one obvious way to accomplish a given task (source: PEP 20).

In Python 2, there were two ways to generate a sequence of integers for iteration: range() and xrange(). One returned a list, the other returned a lazy object. For the overwhelming majority of use cases (looping), xrange() was strictly better. The list-based range() had a niche use when you genuinely needed a list, but that need could be served by wrapping the lazy version in list().

This violated the "one obvious way" principle. Two functions, overlapping purposes, different return types, confusion for beginners about when to use which one. Every Python 2 tutorial had to include the caveat: "use xrange() in loops for better performance." That caveat is itself evidence of a design problem. If the language requires a footnote to explain why you should use one function over another for the same task, the language has one function too many.

The Python 3 cleanup project, codenamed "Python 3000" or "Py3k," made eliminating this kind of duplication a primary goal.

PEP 3100, titled "Miscellaneous Python 3.0 Plans" (authored by Brett Cannon, created August 20, 2004), served as the master list of changes targeted for Python 3.0. The document states its overarching intent: to reduce feature duplication by removing old approaches, guided by the principle that one obvious approach is sufficient (source: PEP 3100). While Brett Cannon authored the PEP, the decisions it cataloged were made by Guido van Rossum.

Under the "To be removed" section, the entry is concise: xrange(): use range() instead. The plan was not to simply rename xrange to range. The plan was to make the new range() function return a lazy, memory-efficient object by default while also making that object significantly more capable than xrange ever was. It was a merge-and-improve, not just a rename.

PEP 3000: The Python 3 Transition Blueprint

PEP 3000, authored by Guido van Rossum, laid out the overarching philosophy of the Python 3 transition. It acknowledged that Python 3.0 would break backward compatibility with Python 2.x, and it outlined the strategy for making that break as manageable as possible. The PEP explicitly states that the implementation would be derived from the Python 2 codebase, reflecting Van Rossum's conviction about the dangers of complete rewrites (source: PEP 3000).

One key element of the transition strategy was the 2to3 tool: a source code converter that could automatically transform Python 2 syntax into Python 3 syntax. The tool included a specific fixer for xrange: it would convert every xrange() call to range(). For range() calls in Python 2, the tool was more cautious. Since Python 2's range() returned a list, the tool could not always know whether the calling code depended on list-specific behavior. In ambiguous cases, it would wrap the call in list(), producing list(range(...)) to preserve the original semantics.

The six compatibility library (whose name comes from the fact that 2 multiplied by 3 equals 6), created by Benjamin Peterson, a CPython core developer and the release manager for Python 2.7 and 3.1 (source: Python Wiki), helped projects support both Python versions simultaneously. It provided six.moves.range as a portable alias that mapped to xrange on Python 2 and range on Python 3.

Python 3's range: Not Just a Renamed xrange

A common misconception is that Python 3 simply renamed xrange to range. In reality, Python 3's range object is a substantially more powerful type. Here are the concrete improvements, each representing a deliberate design decision:

Constant-time containment checks

In Python 2, testing 1000000000 in xrange(2000000000) required iterating through every element because xrange did not implement __contains__. Python's fallback behavior for the in operator when __contains__ is absent is to iterate through the object looking for a match. As Trey Hunner demonstrated, a negative containment check against a billion-element xrange object could take roughly 20 seconds on typical hardware (source: Trey Hunner, 2018). In Python 3, range implements __contains__ with an O(1) arithmetic check: it computes whether the value falls within the start/stop/step parameters without any iteration at all.

The algorithm is straightforward: given a value v, a range with start s, stop e, and step k, Python checks whether v is within bounds and whether (v - s) % k == 0. If both conditions hold, the value is in the range. This is pure arithmetic, and it runs in constant time regardless of the range size.

Sequence-aware equality comparisons

Python 2's xrange fell back to identity comparison. Two different xrange objects with identical parameters would compare as unequal. Python 3's range implements __eq__ and __ne__, so range(10) == range(10) returns True.

Critically, the equality check is smarter than a simple parameter comparison. Since Python 3.3, range equality compares the sequences produced, not just the raw parameters. This means range(0, 10, 1) == range(10) returns True because both produce the same sequence of integers, even though one was constructed with three arguments and the other with one. Conversely, range(0, 10, 3) == range(0, 11, 2) returns False because they produce different sequences. Empty ranges also compare correctly: range(4, 2) == range(0) returns True because both are empty (source: Python Land).

Slicing that returns a range

# Python 3
r = range(100)
print(r[10:20])   # range(10, 20)
print(r[::3])     # range(0, 100, 3)

Python 2's xrange did not support slicing after PEP 260 stripped it. Python 3's range supports slicing natively, and the result is itself another range object, not a list. This is an elegant design choice: the slice of a lazy sequence remains lazy. You can slice a trillion-element range and the result is still a tiny object.

Accessible attributes, count(), and index()

Python 3's range exposes .start, .stop, and .step as read-only attributes. It also supports the full collections.abc.Sequence interface, including count() and index() methods. Python 2's xrange had start, stop, and step attributes in its early life, but PEP 260 stripped them. The count() and index() methods never existed on xrange at all.

Efficient reverse iteration

Python 3's range implements __reversed__, which returns a reverse iterator efficiently without constructing a list. This means reversed(range(1000000)) is instant and memory-free. Python 2's xrange supported reversed() through its __len__ and __getitem__ methods, but Python 3's implementation is explicit and optimized.

Arbitrary-precision integers

As noted above, Python 3's range works with integers of any size, while xrange was limited to C long values. This matters for scientific computing, combinatorics, and any domain where you need to reason about very large integer sequences without materializing them.

Pro Tip

Python 3's range is a fully fledged sequence type, not just an iterator. This means you can pass it to any function expecting a sequence and it will behave correctly, including slicing, length, membership testing, and reverse iteration, all without materializing a list. It satisfies the collections.abc.Sequence abstract base class, which means it interoperates cleanly with any code that programs to the sequence interface.

PEP 322 and How xrange Influenced Other Designs

PEP 322, "Reverse Iteration" (authored by Raymond Hettinger, September 24, 2003), proposed adding the reversed() built-in function. The PEP uses xrange extensively in its examples and motivation. Hettinger pointed to standard library code that used patterns like for i in xrange(len(x)-1, 0, -1) to iterate in reverse, and argued that using reversed() with xrange was far more readable and verifiable (source: PEP 322).

The PEP cites specific standard library examples: random.shuffle() used a descending xrange because the algorithm selects from a shrinking pool, and heapq.heapify() used one because heap ordering proceeds from leaves to root. In both cases, Hettinger showed that wrapping the forward-direction xrange in reversed() made the code's intent clearer while eliminating off-by-one risks from manually computing reverse indices.

PEP 322 was accepted for Python 2.4. The interaction between xrange and reversed() is a good example of how xrange's lazy iteration model influenced the broader Python ecosystem toward iterator-based design patterns, a trend that culminated in Python 3's wholesale embrace of lazy evaluation across built-in functions.

The NameError: What It Means and Why It Still Happens

Even in 2026, with Python 2 officially dead since January 1, 2020, the error NameError: name 'xrange' is not defined continues to appear. Understanding the contexts in which it surfaces helps developers deal with it efficiently and understand its historical context.

Legacy codebases. Organizations that delayed their Python 3 migration or maintain large internal tools written in Python 2 still encounter this when attempting to modernize. The banking, insurance, and government sectors are particularly affected, where codebases can be 15+ years old.

Outdated educational material. Textbooks, university lecture notes, and online tutorials written before 2015 frequently use xrange(). Students working from these materials hit the error immediately. Some popular YouTube tutorials from 2012-2014 still rank highly in search results and use Python 2 syntax exclusively.

Copy-pasted code. Stack Overflow answers from the Python 2 era still receive traffic. A developer who copies a code snippet without checking the Python version will see this error if the snippet uses xrange.

Jupyter notebook environments. Notebooks that have been shared, exported, or archived from the Python 2 era may fail silently on other cells but throw this error at the xrange call.

The fix is always the same: replace xrange() with range(). But understanding why the fix works, and why Python 3's range() is not the same as Python 2's range(), requires the historical context this article provides.

Migrating xrange Code: A Practical Guide

If you are maintaining or porting Python 2 code today, here is what you need to know about handling xrange.

Direct replacement. In the vast majority of cases, every xrange() call can be replaced with range():

# Python 2
for i in xrange(1000):
    process(i)

# Python 3
for i in range(1000):
    process(i)

When you need a list. If the Python 2 code used range() (the list version), and downstream code depends on list operations like appending, slicing by assignment, or repeated random access, wrap the call:

# Python 2
numbers = range(100)
numbers.append(100)

# Python 3
numbers = list(range(100))
numbers.append(100)

Cross-version compatibility. If you need code that runs on both Python 2 and Python 3 (increasingly rare, given Python 2 reached end-of-life on January 1, 2020), the six library provides a clean solution:

from six.moves import range

for i in range(1000):
    process(i)

On Python 2, six.moves.range maps to xrange. On Python 3, it maps to range.

The builtins module compatibility pattern. Another pattern seen in legacy codebases:

import sys
if sys.version_info[0] >= 3:
    xrange = range

This allows code that uses xrange to run unchanged on Python 3 by aliasing range to the xrange name. It works, but it is a band-aid. For any new code, just use range().

Automated conversion. The 2to3 tool handles xrange conversion automatically:

2to3 -w my_script.py

It will rewrite every xrange() call to range() and, where Python 2's list-returning range() was used, may wrap it in list().

A harder migration case: introspecting the xrange type. Some Python 2 code checks isinstance(obj, xrange) or references the xrange type directly. In Python 3, the equivalent check is isinstance(obj, range). The 2to3 tool handles this automatically, but custom metaclass code or type-dispatch systems may need manual review.

Proving It with Real Code

Here are measurable demonstrations of the memory and performance differences, since PythonCodeCrack is about real code and real comprehension, not copy-paste tutorial content.

Memory comparison (run in Python 2):

# Python 2
import sys

list_range = range(1000000)
lazy_range = xrange(1000000)

print("range() size:", sys.getsizeof(list_range))
# Output: range() size: 8000072

print("xrange() size:", sys.getsizeof(lazy_range))
# Output: xrange() size: 40

The list consumes roughly 8 megabytes. The xrange object consumes 40 bytes. That is a factor of 200,000. And this ratio grows linearly with the range size: range(10000000) uses 80 MB, while xrange(10000000) still uses 40 bytes.

Containment check speed (Python 2 vs Python 3):

# Python 2 (SLOW - linear scan)
import time
start = time.time()
result = 999999999 in xrange(1000000000)
print("xrange containment:", time.time() - start, "seconds")
# Takes several seconds or more depending on hardware
# Python 3 (FAST - O(1) arithmetic)
import time
start = time.time()
result = 999999999 in range(1000000000)
print("range containment:", time.time() - start, "seconds")
# Essentially instantaneous

The Python 3 version is not iterating. It is computing (999999999 - start) % step == 0 and checking bounds. Pure math, constant time.

Equality semantics (Python 3):

# Python 3 - equality checks the sequence produced, not the parameters
print(range(0, 10, 1) == range(10))     # True (same sequence)
print(range(0, 10, 3) == range(0, 11, 2))  # False (different sequences)
print(range(4, 2) == range(0))          # True (both empty)

This behavior was introduced in Python 3.3 and demonstrates that range equality is genuinely semantic, not syntactic.

How Python's Approach Compares to Other Languages

Python's handling of the xrange-to-range transition reflects a broader industry trend toward lazy evaluation, but different languages arrived at the same destination through different paths. Understanding these parallels sharpens your mental model of why Python made the choices it did.

Java. Java's IntStream.range() (introduced in Java 8, 2014) produces a lazy, sequential stream of integers. Like Python 3's range, it does not materialize a list. Unlike Python's range, it is a true stream: single-pass by default, though it can be collected into a list. Java arrived at lazy integer sequences six years after Python 3.0 shipped them.

Rust. Rust's 0..n range syntax produces an iterator that generates values lazily. Rust ranges are single-pass iterators by default, closer to Python generators than to Python 3's multi-pass range. However, Rust's ownership model ensures that the range is consumed rather than shared, side-stepping the mutability issues that motivated xrange's design in the first place.

JavaScript. JavaScript has no built-in range type. Developers typically use Array.from({length: n}, (_, i) => i) or loops, which eagerly construct arrays. Libraries like Lodash provide _.range(), but it also returns a materialized array. Python's lazy-by-default approach remains more memory-efficient than JavaScript's common patterns for this use case.

Haskell. Haskell's [1..n] syntax produces a lazy list by default, thanks to the language's pervasive lazy evaluation. In a sense, Haskell never had the range/xrange split because laziness was always the default. Python's journey from eager range to lazy range can be seen as a pragmatic version of what Haskell does by design.

The common thread is that every mature language eventually recognizes that materializing large sequences eagerly is wasteful. Python's contribution to this pattern was doing it as a deliberate, backward-incompatible redesign rather than adding yet another function alongside the existing one.

The Broader Lesson: Python 3's Lazy-by-Default Philosophy

The removal of xrange() was not an isolated event. It was part of a systematic shift in Python 3 toward lazy evaluation as the default behavior for built-in functions. Consider how many Python 2 functions returned lists that were changed to return iterators or lazy objects in Python 3:

  • map() returned a list in Python 2; it returns an iterator in Python 3.
  • filter() returned a list in Python 2; it returns an iterator in Python 3.
  • zip() returned a list of tuples in Python 2; it returns an iterator in Python 3.
  • dict.keys(), dict.values(), and dict.items() returned lists in Python 2; they return view objects in Python 3.

In every case, the reasoning was the same: constructing a full list is wasteful when the caller is just going to iterate through it. By defaulting to lazy evaluation, Python 3 reduced memory overhead across the board and encouraged developers to think in terms of iteration rather than list manipulation.

But the shift went deeper than memory optimization. It changed how developers compose operations. In Python 2, chaining map() and filter() meant building an intermediate list at each step. In Python 3, those intermediate representations never materialize. The data flows through the pipeline element by element. This is not just more efficient; it enables patterns that were impractical in Python 2 due to memory constraints.

Design Context

This design philosophy ties back to one of Guido van Rossum's stated goals for Python 3, as articulated in PEP 3000. The transition was about cleaning up accumulated design decisions from Python's early years that, with the benefit of hindsight, prioritized convenience over efficiency. xrange() existing alongside range() was one of those decisions. Fixing it meant merging the two, keeping the lazy behavior, and building a more capable object on top.

The Cognitive Shift: Thinking in Sequences, Not Lists

The xrange-to-range transition encodes a deeper cognitive shift that separates proficient Python developers from those who learned from Python 2-era tutorials. The shift is this: a sequence is not a data structure. A sequence is a contract.

When you write range(1000000) in Python 3, you are not creating a million integers. You are creating an object that promises to produce a million integers if asked. This promise is lazy, reusable, sliceable, and testable. It satisfies the collections.abc.Sequence interface. Any function that accepts a sequence can accept it. But it never allocates more than a few dozen bytes.

This distinction has practical consequences. Consider a function that accepts a "list of indices" as a parameter. If the caller passes range(1000000), and the function only checks membership or iterates, everything works perfectly and no memory is wasted. But if the function calls .append(), it will fail because range is immutable. This failure is informative: it tells you that the function is exceeding the contract it should be programming to. It should accept a sequence, not demand a list.

The lesson extends beyond range. Every time you use list() to convert a lazy object into a list, ask yourself: do I actually need a list, or do I just need to iterate? If you only need to iterate, the lazy object is sufficient and often superior. This question, applied habitually, produces code that is both more memory-efficient and more composable.

Python 3's range is not just a replacement for xrange. It is a teaching tool. It demonstrates that the most efficient data structure is often no data structure at all, just a computation waiting to happen.

Summary: The Timeline

Here is the full arc of xrange() in Python's history:

  • Python 2.0 (October 2000) shipped with both range() (returns a list) and xrange() (returns a lazy xrange object). Both served the same fundamental purpose of generating integer sequences, but their implementations diverged on memory strategy.
  • PEP 260 (June 2001) authored by Guido van Rossum, proposed and achieved the simplification of xrange, stripping away rarely-used sequence behaviors that had accumulated bugs.
  • Python 2.2 (December 2001) implemented PEP 260's changes, with deprecated features triggering warnings.
  • PEP 322 (September 2003) authored by Raymond Hettinger, proposed the reversed() built-in and used xrange extensively in its motivation, demonstrating how lazy iteration patterns were shaping the language's evolution.
  • PEP 3100 (August 2004) authored by Brett Cannon (decisions by Guido van Rossum), listed xrange for removal in Python 3 as part of a broader effort to eliminate feature duplication.
  • Python 2.4 (November 2004) fully removed the deprecated xrange features from PEP 260.
  • Python 3.0 (December 2008) removed xrange() entirely. The range() function now returned a lazy range object with capabilities exceeding those of the old xrange.
  • Python 2.7 (July 2010), the final Python 2 release, still included xrange. It reached its official end of life on January 1, 2020, with the final release (2.7.18) shipping on April 20, 2020, marking the end of Python 2 entirely.
  • Python 3.3 (September 2012) added sequence-aware equality comparisons to range, so that two range objects producing the same sequence compare as equal regardless of how they were constructed.

The story of xrange() is, in miniature, the story of Python's maturation as a language: a useful but imperfect tool was refined, simplified, superseded, and ultimately absorbed into something better. It is also a story about the courage required to make backward-incompatible changes in service of long-term design coherence. If you are writing Python today, just use range(). And know that every time you do, you are benefiting from over two decades of design iteration that got it right.

back to articles