Placement Prep

range vs xrange in Python: What Changed in Python 3

Python 2 had both range() and xrange(). Python 3 removed xrange() and made range() lazy. This guide covers memory behaviour, indexing gotchas, and when to use list().

By FACE Prep Team 5 min read
python range xrange python-3 placement-prep memory-management python-basics

xrange() existed only in Python 2; Python 3 removed it and gave range() its memory-efficient, lazy behaviour.

For students moving from Python 2 to Python 3, or reading legacy tutorials that mention xrange: there is no xrange() in Python 3. Calling it raises a NameError. The practical question is not “which one do I use?” but “how does Python 3’s range() behave differently from Python 2’s range()?”

What range() and xrange() were in Python 2

Python 2 shipped with two range-like built-in functions, and the difference between them mattered for memory:

  • range(start, stop, step) returned a fully materialised list. Calling range(1000) built a list of 1,000 integers in memory before the loop started executing.
  • xrange(start, stop, step) returned a lazy range object. No list was built. Values were computed one at a time as iteration requested them.

Both accepted the same three arguments. start defaults to 0, step defaults to 1, and stop is the only required parameter.

# Python 2 only — do NOT run in Python 3
print(range(1, 9, 2))    # [1, 3, 5, 7]
print(xrange(1, 9, 2))   # xrange(1, 9, 2)

The print output is already different: range() showed a list; xrange() showed the object representation. Iterating over both in a for loop produced the same sequence of values, but only xrange() avoided building the list first.

For small ranges, the memory difference was invisible. For a loop counting to a million, xrange() used a fixed amount of memory while range() held a million-element list in RAM for the entire duration of the loop.

Python 3: one range() to rule them both

Python 3 removed xrange() and redesigned range() to behave as xrange() did. The Python 3.0 release notes state this directly: “range() now behaves like xrange() used to behave, except it works with values of arbitrary size.”

In Python 3, range() returns a range object, not a list:

# Python 3
r = range(1, 9, 2)
print(r)        # range(1, 9, 2)
print(type(r))  # <class 'range'>

The for loop behaviour is unchanged:

for n in range(1, 9, 2):
    print(n)
# Output: 1  3  5  7  (each on a separate line)

Migration note: if your Python 2 code passed the output of range() directly to something expecting a list, wrap it: list(range(1, 9, 2)) gives [1, 3, 5, 7]. In Python 3, this wrapping is only needed when you specifically need a list, not for iteration.

Memory profile: sys.getsizeof in action

The clearest way to see the difference is sys.getsizeof, which returns an object’s memory footprint in bytes.

import sys

# Python 3 — range size stays constant regardless of n
print(sys.getsizeof(range(10)))           # 48
print(sys.getsizeof(range(1_000)))        # 48
print(sys.getsizeof(range(1_000_000)))    # 48

# Converting to list shows the actual allocation cost
print(sys.getsizeof(list(range(10))))          # 184
print(sys.getsizeof(list(range(1_000))))       # 8056
print(sys.getsizeof(list(range(1_000_000))))   # 8000056

The range object always reports the same size because it stores exactly three integers: start, stop, and step. That is all the information needed to generate any value in the sequence on demand. The list grows linearly because it holds a pointer to every element.

The Python 3 range documentation describes range objects as implementing “constant time” membership tests and defining a __len__ that computes the count arithmetically rather than by counting elements.

For a placement coding round, the practical takeaway is: use range() directly in loops. Don’t convert to a list first unless the code genuinely needs a list. The memory saving is measurable at scale, and more importantly, it shows you understand how the built-in works.

Indexing, slicing, and the O(1) membership test

Python 2’s xrange() supported indexing but not slicing. Python 3’s range() supports both, and adds an optimised membership test.

Indexing

r = range(10)
print(r[3])    # 3
print(r[-1])   # 9  — negative indexing works
print(r[-2])   # 8

Indexing computes the value arithmetically: r[i] returns start + i * step. For range(10), that is 0 + 3 * 1 = 3. No list traversal.

Slicing

r = range(10)
print(r[2:7])    # range(2, 7)
print(r[::2])    # range(0, 10, 2)
print(r[1:8:3])  # range(1, 8, 3)

Slicing returns another range object, not a list. Range objects are immutable, so r[2:5].append(99) raises an AttributeError. Use list(r[2:5]) if you need a mutable sequence from a slice.

Membership test

print(999_999 in range(1_000_000))   # True
print(1_500_000 in range(1_000_000)) # False

Python 3’s range __contains__ does not iterate. It checks whether the candidate satisfies the start/stop/step constraints using arithmetic. This makes x in range(n) O(1) rather than O(n), which matters when you are checking whether a value falls inside a range without wanting to build the sequence first.

Python 2’s xrange() did not have this optimisation. Checking membership required iterating through values one by one, making it O(n) rather than O(1).

When to convert range() to list()

For the majority of placement coding problems, you will not need to convert. Range objects iterate, index, slice, and support membership checks directly.

Convert with list() when you specifically need to:

  • Print the full sequence on one line: print(list(range(5))) outputs [0, 1, 2, 3, 4].
  • Append, remove, or modify elements: range objects are immutable.
  • Pass to a function that explicitly requires a list, not just an iterable.
  • Reverse a range: list(reversed(range(5))) gives [4, 3, 2, 1, 0]. (In Python 3, reversed(range(n)) works directly, but some code paths expect a list.)

For iteration in loops, no conversion is needed:

# Correct — no conversion
for i in range(100_000):
    do_something(i)

# Unnecessary — wastes memory
for i in list(range(100_000)):
    do_something(i)

In a Python basic programs guide, range() is the natural default for any counted loop. When computing a sum of array elements in Python, iterating with for i in range(len(arr)) avoids building any intermediate list. The same applies when you need character-position indices in string operations like alphabetically sorting a string in Python.

Python 2 vs Python 3: side-by-side comparison

FeaturePython 2 range()Python 2 xrange()Python 3 range()
Return typeFull listLazy range objectLazy range object
Memory for large nGrows with nFixed (small)Fixed (small)
IndexingYes (list)YesYes (range object)
SlicingReturns new listRaises TypeErrorReturns new range
in operatorO(n) list scanO(n) iterationO(1) arithmetic
Available in Python 3RedesignedRemovedBuilt-in

The Python 3 redesign was a straightforward choice: keep the API surface (range()), change the return type to the already-proven lazy model from xrange(), and add indexing and slicing capabilities that xrange() never had. Two functions became one, with better behaviour than either alone.

In campus placement rounds in India, questions on this topic appear in two forms: a conceptual multiple-choice item asking what xrange() returns in Python 2 (lazy range object, not a list), and a memory-efficiency question asking why sys.getsizeof(range(n)) stays constant in Python 3. Both trace back to the same underlying concept: a range object stores the formula, not the values.

The lazy-evaluation pattern behind range() is the same principle generators and LLM token streams use: produce values when asked rather than materialising the entire sequence upfront. TinkerLLM covers generators and streaming LLM output in short Python exercises, showing where this pattern appears in AI applications at an entry price of ₹299.

Primary sources

Frequently asked questions

Does Python 3 have xrange?

No. Python 3 removed xrange() entirely. In Python 3, use range() instead — it behaves the same way xrange() did in Python 2, generating values lazily without building a list.

Why was xrange removed in Python 3?

Python 3 unified the API by making range() itself lazy. Keeping a separate xrange() alongside a list-returning range() was redundant once range() was redesigned to be memory-efficient by default.

Is Python 3 range() the same as Python 2 xrange()?

Functionally very close. Both generate values lazily and store only start, stop, and step. Python 3's range() also adds slicing support and an O(1) membership test that Python 2's xrange() did not have.

How do I convert range to a list in Python 3?

Wrap it in list(): list(range(5)) gives [0, 1, 2, 3, 4]. This is the Python 3 equivalent of Python 2's range(), which returned a list directly.

Can range() be used in a for loop without converting to list?

Yes, and this is the preferred usage. Python's for loop iterates over any iterable, so for i in range(10): works directly on the range object with no conversion needed.

What does sys.getsizeof show for range() in Python 3?

sys.getsizeof(range(n)) returns 48 bytes regardless of n, because only three integers (start, stop, step) are stored internally. The equivalent list grows with n.

Did xrange() support slicing in Python 2?

No. In Python 2, attempting to slice xrange() raised a TypeError. Python 3's range() supports slicing and returns another range object, not a list.

Build AI projects

A self-paced playground for building with LLMs.

TinkerLLM is FACE Prep's sister property. A guided environment for shipping real LLM applications, the kind of project that earns a paragraph on your resume, not a line.

Try TinkerLLM (₹299 launch)
Free AI Roadmap PDF