Python List Conversion: What It Is, When You Need It, and When You Don't

Python 3 changed the rules. Functions that once returned lists -- map(), filter(), zip(), range(), dict.keys(), dict.values(), dict.items() -- now return lazy objects: iterators, views, and sequences that produce values on demand instead of materializing them all at once.

This was a deliberate design decision driven by memory efficiency and performance, and it reshaped how Python programmers think about data. The consequence is that converting something to a list -- calling list() on it -- became a question that every Python developer faces regularly. When do you actually need a concrete list in memory? When is it wasteful? And when does skipping the conversion introduce subtle bugs?

This article answers those questions. Real code, real reasoning, real comprehension.

What list() Actually Does

The built-in list() is a constructor for Python's list type. It does one of two things depending on how you call it.

Called with no arguments, it creates an empty list:

empty = list()
print(empty)  # []

Called with a single iterable argument, it consumes that iterable and stores every element in a new list, in order:

list(range(5))              # [0, 1, 2, 3, 4]
list("hello")               # ['h', 'e', 'l', 'l', 'o']
list((10, 20, 30))          # [10, 20, 30]
list({3, 1, 2})             # [1, 2, 3] (set order may vary)
list(reversed([1, 2, 3]))   # [3, 2, 1]

Under the hood, list() calls the iterable's __iter__ method to get an iterator, then repeatedly calls __next__ on that iterator until StopIteration is raised, appending each value to a new list object. This means list() works with anything you can put in a for loop: generators, file objects, map objects, zip objects, dictionary views, custom iterables -- all of it.

Python educator Trey Hunner described the list constructor on his Python Morsels blog as having a clear and singular purpose: "take an iterable and turn it into a list."

Why Python 3 Made This Necessary

In Python 2, calling map(str.upper, words) returned a list directly. So did filter(), zip(), range(), and the dictionary methods .keys(), .values(), and .items(). You rarely had to think about conversion because these functions already gave you a list.

Python 3 changed all of that. PEP 3100 (Miscellaneous Python 3.0 Plans), which catalogued the design goals for Python 3 under Guido van Rossum's direction, explicitly listed the objective to make built-ins return an iterator where appropriate -- including range(), zip(), map(), and filter(). The old list-returning behavior was removed, and xrange() was retired in favor of the new lazy range type.

The Python 3 Porting Guide explains the reasoning: the main motivation for the change is that iterators generally offer better memory consumption than lists. A map object doesn't allocate space for every result upfront. A zip object doesn't create thousands of tuples you might never need. A range object stores three integers regardless of whether it represents ten values or ten billion.

Note

Laziness comes with trade-offs. An iterator can only be consumed once. You cannot index into it, check its length, or slice it. And if the underlying data changes between the time you create the iterator and the time you consume it, the results may surprise you. These trade-offs are exactly what determine when you need list() and when you don't.

When You Need List Conversion

When You Need to Access Elements by Index

Iterators have no __getitem__ method. If your code needs to reach into the result and grab the third element, or slice out a chunk, you need a list.

results = map(str.upper, ["apple", "banana", "cherry"])

# This fails:
# results[1]  # TypeError: 'map' object is not subscriptable

# This works:
results_list = list(map(str.upper, ["apple", "banana", "cherry"]))
print(results_list[1])   # 'BANANA'
print(results_list[:2])  # ['APPLE', 'BANANA']
Pro Tip

range objects are a special case -- they support indexing and slicing natively without conversion to a list. But map, filter, zip, and generator objects do not.

When You Need to Iterate Multiple Times

Iterators are consumed after a single pass. Once you have iterated through a map object or a generator, it is exhausted. Calling list() on it a second time returns an empty list.

squares = map(lambda x: x**2, [1, 2, 3, 4, 5])

print(list(squares))  # [1, 4, 9, 16, 25]
print(list(squares))  # [] -- exhausted!

If you need the data more than once -- to iterate, then sort, then filter, then display -- convert it to a list upfront.

squares = list(map(lambda x: x**2, [1, 2, 3, 4, 5]))

total = sum(squares)
average = total / len(squares)
maximum = max(squares)
# All three work because squares is a list, not an exhausted iterator

When You Need to Know the Length

The len() function requires an object with a __len__ method. Iterators don't have one.

filtered = filter(lambda x: x > 50, range(100))

# This fails:
# len(filtered)  # TypeError: object of type 'filter' has no len()

# This works:
filtered_list = list(filter(lambda x: x > 50, range(100)))
print(len(filtered_list))  # 49

Again, range is the exception. len(range(100)) works perfectly and runs in constant time.

When You Need to Mutate the Result

Lists are mutable. Iterators and lazy objects are not. If you need to append, insert, remove, sort in place, or reverse in place, you need a list.

data = sorted(map(int, ["3", "1", "4", "1", "5"]))
# sorted() returns a new list -- you already have one

# But if you need in-place operations:
data = list(map(int, ["3", "1", "4", "1", "5"]))
data.append(9)
data.sort()
data.reverse()

When You Need to Serialize or Store the Data

If you are writing data to JSON, passing it to an API that expects a list, storing it in a database, or returning it from a function where the caller expects a list, convert it.

import json

# json.dumps won't serialize a map object
data = map(lambda x: x * 2, range(5))
# json.dumps(data)  # TypeError: Object of type map is not JSON serializable

data = list(map(lambda x: x * 2, range(5)))
json.dumps(data)  # '[0, 2, 4, 6, 8]'

When You Need a Shallow Copy

Passing a list to list() creates a shallow copy. This is a clean, readable way to duplicate a list so that mutations to one don't affect the other.

original = [1, 2, 3, 4, 5]
copy = list(original)

copy.append(6)
print(original)  # [1, 2, 3, 4, 5] -- unchanged
print(copy)      # [1, 2, 3, 4, 5, 6]

This is functionally equivalent to original[:] or original.copy(), but list() works on any iterable, not just lists. If you don't know the exact type of the incoming iterable and you want a list, list() is the universal tool.

When You Don't Need List Conversion

This is where the real performance and design gains live. Many Python developers reflexively wrap everything in list() out of habit, and it costs them memory and speed for no benefit.

When You Only Need to Loop Once

If you are going to iterate through the result with a for loop and never touch it again, there is no reason to create a list.

# Unnecessary conversion
for name in list(map(str.strip, raw_names)):
    print(name)

# Better -- just iterate directly
for name in map(str.strip, raw_names):
    print(name)

The second version processes each element on demand without allocating a list to hold all the stripped strings simultaneously.

When Passing to a Function That Accepts Any Iterable

Many Python functions and methods accept any iterable, not just lists. You gain nothing by converting first.

# Unnecessary
",".join(list(map(str, numbers)))

# Better
",".join(map(str, numbers))

The str.join() method accepts any iterable of strings. So do sum(), min(), max(), any(), all(), sorted(), set(), tuple(), collections.Counter(), and many others. PEP 289 (Generator Expressions), authored by Raymond Hettinger, specifically highlighted how generator expressions are particularly useful with functions like sum(), min(), and max() that reduce an iterable to a single value. The same principle applies to any lazy iterable: if the consuming function already accepts iterables, let it consume directly.

When Working with Large Data Sets

This is where laziness matters most. Converting a large iterator to a list forces the entire dataset into memory at once.

# Dangerous with a 10 million line file
all_lines = list(open("massive_log.txt"))  # Loads everything into RAM

# Better -- process line by line
with open("massive_log.txt") as f:
    for line in f:
        if "ERROR" in line:
            process_error(line)

PEP 289 made this argument explicitly when it was accepted for Python 2.4. The PEP noted that while list comprehensions had shown their widespread utility throughout Python, many use cases do not need to have a full list created in memory. As data volumes grow, generators tend to perform better because they do not exhaust cache memory and they allow Python to re-use objects between iterations.

When Using range for Iteration

This is a specific case that catches beginners. In Python 3, range() returns a lazy sequence object, not a list. Since range objects already support iteration, membership testing, indexing, slicing, and length queries, converting to a list almost never makes sense.

# Almost never necessary
for i in list(range(100)):
    print(i)

# Just use range directly
for i in range(100):
    print(i)

The only time you would convert a range to a list is when you specifically need a mutable list of integers -- for example, to shuffle them in place or pass them to a function that type-checks for list.

list() vs. List Comprehensions vs. Generator Expressions

Python offers three distinct tools for producing lists from iterables, and choosing the right one matters for both readability and performance.

list() -- Converting an Existing Iterable

Use list() when you already have an iterable and want to materialize it without any transformation.

words = list(file_object)               # Each line becomes a list element
numbers = list(range(100))              # Materialize a range
unique_sorted = list(sorted(set(data))) # Chain of operations

List Comprehensions -- Building with Transformation or Filtering

Use a list comprehension when you need to transform, filter, or otherwise process elements while building the list.

squares = [x**2 for x in range(10)]
evens = [x for x in data if x % 2 == 0]
cleaned = [line.strip().lower() for line in raw_lines if line.strip()]

List comprehensions were introduced in PEP 202, which proposed them as a more concise way to create lists in situations where map() and filter() and/or nested loops would otherwise be used. They have since become one of Python's defining features.

Generator Expressions -- When You Don't Need the List at All

Use a generator expression (parentheses instead of brackets) when the consuming function accepts any iterable and you don't need to keep the data around.

total = sum(x**2 for x in range(1000000))     # No list created
has_errors = any(line.startswith("ERROR") for line in log_file)
longest = max(len(word) for word in vocabulary)

The generator expression (x**2 for x in range(1000000)) never creates a million-element list. It feeds values to sum() one at a time, using minimal memory. Replace the parentheses with brackets and you get a list comprehension that allocates an entire list of a million integers before sum() even starts.

Raymond Hettinger and several other core developers argued forcefully for generator expressions during PEP 289's development, and Alex Martelli provided the performance measurements that proved their benefits. Tim Peters suggested the name "generator expressions" during the discussion, and Guido van Rossum made the final design decisions.

A Decision Framework

Ask yourself these questions in order:

  1. Do I need to transform or filter elements? If yes, use a list comprehension (or a generator expression if you don't need the list).
  2. Do I need indexing, length, mutation, or multiple passes? If yes, you need a list. Use list() on the iterable, or use a list comprehension if transformation is also needed.
  3. Am I passing the result to a function that accepts any iterable? If yes, and you don't need the data again, skip the list. Use the iterator or a generator expression directly.

Common Anti-Patterns

list(list(x)) -- Double Conversion

If x is already a list, wrapping it in list() creates an unnecessary copy. This is only useful when you deliberately want a shallow copy.

list() Around a List Comprehension

# Redundant -- the comprehension already produces a list
result = list([x**2 for x in range(10)])

# Just use the comprehension
result = [x**2 for x in range(10)]

A list comprehension returns a list. Wrapping it in list() creates a second list from the first, wastes memory temporarily, and adds noise.

Converting When You Only Unpack

# Unnecessary
a, b, c = list(map(int, ["1", "2", "3"]))

# Unpacking works on any iterable
a, b, c = map(int, ["1", "2", "3"])

Python's unpacking syntax works with any iterable, not just lists.

Using list(range(len(x))) for Indices

# Dated pattern
for i in list(range(len(data))):
    print(data[i])

# Better
for i, item in enumerate(data):
    print(item)

The enumerate() function, introduced by PEP 279 and accepted into Python 2.3, was specifically designed to replace the range(len(x)) idiom. It yields (index, element) tuples directly, eliminating both the list conversion and the manual indexing.

Warning

If you find yourself writing list(range(len(something))), stop. Use enumerate() instead. The old pattern is slower, less readable, and produces an unnecessary intermediate list.

Performance Considerations

The cost of list() is proportional to the number of elements in the iterable. For each element, Python must call __next__ on the iterator, allocate a reference in the list's internal array, and potentially resize that array as it grows.

For small collections (dozens to hundreds of elements), this cost is negligible. For large collections (millions of elements), it becomes significant -- both in time and memory.

import sys

# A generator expression uses almost no memory
gen = (x**2 for x in range(1_000_000))
print(sys.getsizeof(gen))  # ~200 bytes

# A list comprehension stores everything
lst = [x**2 for x in range(1_000_000)]
print(sys.getsizeof(lst))  # ~8,000,000+ bytes

CPython's list implementation uses a contiguous array of references, as described in the Python Design and History FAQ. When items are appended, the array is occasionally resized with some extra space pre-allocated to make successive appends efficient. This is the well-known amortized O(1) append strategy. But even with this optimization, creating a million-element list still requires allocating and populating a million-slot array, which takes both time and memory.

Pro Tip

Treat list() as a deliberate commitment to store data in memory. If you don't need that commitment, don't make it.

Working with Dictionary Views

One of the more subtle changes in Python 3 involves dictionary methods. In Python 2, dict.keys(), dict.values(), and dict.items() returned lists. In Python 3, they return view objects.

Views are live -- they reflect changes to the underlying dictionary -- and they are iterable. But they are not lists.

d = {"a": 1, "b": 2, "c": 3}

keys = d.keys()
print(type(keys))  # <class 'dict_keys'>

# You can iterate over a view
for k in keys:
    print(k)

# But you cannot index into it
# keys[0]  # TypeError: 'dict_keys' object is not subscriptable

# Convert when you need list operations
keys_list = list(keys)
print(keys_list[0])  # 'a'

A common scenario where conversion is genuinely useful: you need to iterate over a dictionary's keys while modifying the dictionary. Iterating over the view directly while adding or removing keys raises a RuntimeError. Converting to a list first creates a snapshot of the keys.

d = {"a": 1, "b": 2, "c": 3}

# This raises RuntimeError: dictionary changed size during iteration
# for k in d:
#     if d[k] < 2:
#         del d[k]

# This works
for k in list(d):
    if d[k] < 2:
        del d[k]

print(d)  # {'b': 2, 'c': 3}

The Sorted Shortcut

The sorted() built-in already returns a new list. There is no need to wrap it in list().

# Redundant
result = list(sorted(data))

# Already a list
result = sorted(data)

This is a small thing, but it comes up often enough to be worth remembering. sorted() accepts any iterable and always returns a list, making it one of the few built-in functions that already does the conversion for you.

Conclusion

List conversion in Python is not a mechanical step you apply everywhere. It is a design decision with real trade-offs. Converting a lazy iterable to a list commits memory, forces immediate evaluation, and creates a mutable, indexable, re-iterable snapshot of the data. Sometimes that is exactly what you need. Sometimes it is exactly what you should avoid.

The guiding principle is simple: keep data lazy as long as possible, and materialize it into a list only when you have a concrete reason -- indexing, length checking, mutation, multiple passes, or serialization. This approach aligns with the philosophy that drove Python 3's shift toward iterators and lazy evaluation, a philosophy that PEP 3100 codified and that PEP 289's generator expressions made practical and elegant.

Write list() when you mean it. Leave it out when you don't.

back to articles