Two ways to write the same thing

squares_list = [x * x for x in range(5_000_000)]
squares_gen = (x * x for x in range(5_000_000))

Square brackets versus parentheses. Both produce something you can iterate over. Both, summed, give you the identical total. It's easy to treat them as interchangeable syntax preference, especially since so many tutorials introduce generator expressions as "just like list comprehensions, but lazy" without spelling out what that costs or saves.

What "lazy" actually means, measured

import tracemalloc

tracemalloc.start()
squares_list = [x * x for x in range(5_000_000)]
_, peak = tracemalloc.get_traced_memory()
print(f"list comprehension peak memory: {peak / 1024 / 1024:.1f} MB")
list comprehension peak memory: 194.5 MB
tracemalloc.start()
squares_gen = (x * x for x in range(5_000_000))
_, peak = tracemalloc.get_traced_memory()
print(f"generator expression peak memory: {peak / 1024:.2f} KB")
generator expression peak memory: 0.37 KB

194.5 MB versus 0.37 KB, for defining a sequence of the same 5 million numbers. The list comprehension does exactly what its name says: it comprehends the whole list, immediately, and holds every element in memory at once. The generator expression does none of that work up front. It stores the recipe for producing each value, not the values themselves, and only computes an item the moment something actually asks for the next one.

The part that matters more than memory

Memory is the easy number to point at, but the real practical difference shows up whenever you don't need every item. Say you're searching a sequence for the first value matching some condition:

def matches(x):
    return x % 1000003 == 500000

# Approach 1: build the whole list, then take the first match
matches_list = [x for x in range(5_000_000) if matches(x)]
first = matches_list[0]

# Approach 2: a generator, stopped the instant a match is found
matches_gen = (x for x in range(5_000_000) if matches(x))
first = next(matches_gen)
list comprehension approach: 328.7 ms
generator approach: 27.6 ms

Both find the same answer, 500000. The list version checked every one of 5 million numbers before returning anything, because it has to finish building the whole list before [0] can even be evaluated. The generator version checked numbers one at a time and stopped the moment it found a match, roughly twelve times faster here, and the gap only grows if the real match is even further into the sequence, or if matches() itself is a real, expensive check rather than a cheap modulo.

The real tradeoff, not just the upside

A generator isn't strictly better, it trades away something specific: you can only go through it once.

gen = (x * x for x in range(5))
print(list(gen))
print(list(gen))
[0, 1, 4, 9, 16]
[]

The second pass comes back empty. A generator has no memory of where it started, only where it currently is; once exhausted, it stays exhausted. A list, by contrast, can be iterated as many times as you want, indexed into directly, checked for length with len(), and sliced, none of which a plain generator supports. If you need to loop over the same data twice, or you need results[7] without walking through the first seven items, a list is the correct choice, not a generator forced to behave like one.

The takeaway

Reach for a generator when the sequence is large, when you might stop early, or when you're only ever going to consume it once, start to finish. Reach for a list when you need to revisit the data, index into it, or know its length up front. The syntax difference is one character. The memory and performance difference, in the cases where it matters, can be several orders of magnitude, and the only way to know which case you're in is to check what your code actually does with the result, not just what it computes.