skip to content

Laziness, Memory and Streaming

A generator produces one item at a time instead of materializing a list, so you can stream a huge file in near-constant memory. No len(), no indexing, one pass only is the follow-up.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

How does a generator expression's memory use differ from a list comprehension?

level: juniorimportance: must knowfreq 78%

answer

  1. Think about when the elements exist
  2. One at a time versus all at once
  3. Brackets build now, parentheses defer
  4. Peak memory tracks one item, not N

basics

~20 s

A list comprehension builds every element and holds them all at once. A generator expression builds an object that produces one element per next() call, so peak memory tracks a single item rather than the whole result.

solid answer

~50 s

The two syntaxes differ in **when the elements exist**. `[f(x) for x in src]` runs the loop immediately and hands back a `list` holding N results; `(f(x) for x in src)` returns a generator object that has computed nothing yet and computes one value each time it is advanced. So the list's memory grows with N, while the generator expression's stays roughly flat: the current item, the loop variable, and the suspended frame. That makes the generator form the right default for feeding `sum()`, `max()`, `any()`, a `for` loop, or another generator stage. Materialize a list when you need the data more than once, need a length, or need indexing. And note that laziness is cancelled the moment a downstream consumer buffers - `sorted()` or `list()` around a generator expression allocates the full result anyway.

code

python · 8 lines
python
def cost(n):
    print("computing", n)
    return n * n

first_from_list = [cost(n) for n in range(3)][0]   # computes 0, 1 and 2
print("---")
first_from_gen = next(cost(n) for n in range(3))   # computes only 0
print(first_from_list, first_from_gen)

go deeper

for a junior

Be ready to state the one-character difference and what it buys: brackets build the whole list now, parentheses hand back something that produces items one at a time. Knowing when each is appropriate is enough at this level.

for a middle

Explain the mechanics: the generator object holds a suspended frame, each advance resumes it for exactly one value, and peak memory tracks the largest single item rather than N. Name the consumers that cancel the benefit, such as sorted() and list().

for a senior

An interviewer expects you to spot the buffering stage in real code - a sorted(), a join, an accumulator list - that quietly makes a 'streaming' pipeline hold everything. Also be ready to argue for the list form when repeat traversal or length is needed.

for a principal

Own the tradeoff at design level: streaming lowers peak memory but changes where exceptions surface, when side effects happen, and how work is attributed in a profile. Decide when the operational simplicity of materializing a bounded batch beats a fully lazy chain.

A list comprehension and a generator expression look almost identical - swap the square brackets for parentheses - and that one character changes the memory profile of the whole program. ## Two different objects `squares = [n * n for n in range(1_000_000)]` runs its loop to completion right there. When the statement finishes, `squares` is a `list` holding one million integer objects, and every one of them is alive because the list references them. Peak memory is proportional to N. `squares = (n * n for n in range(1_000_000))` does not run the loop at all. It creates a **generator object**: a small object wrapping a suspended frame. Nothing has been multiplied, nothing has been stored. Each time something advances the generator - a `for` loop, `next()`, `sum()` - the frame resumes, runs until the next `yield` the compiler generated for you, hands back one value, and suspends again. The value the consumer just received is the only element the generator itself keeps alive; once the consumer drops it, it can be collected. This is why the generator form is described as producing items in near-constant memory. "Near" matters: the generator's own footprint is the frame plus whatever its locals hold, and the *individual item* still has to fit. Streaming a file whose single longest line is 200 MB is not constant memory in any useful sense. What is constant is that memory does not grow with the *number* of items. ## Where the saving actually lands The saving is real only if nothing downstream re-materializes the stream. These stay flat: ```python total = sum(n * n for n in range(10_000_000)) biggest = max(len(name) for name in names) if any(row.startswith("#") for row in rows): ... ``` These do not, and it is the most common way engineers lose the benefit without noticing: ```python ordered = sorted(n * n for n in huge) # sorting needs every element at once cached = list(n * n for n in huge) # explicit re-materialization joined = ", ".join(str(n) for n in huge) # str.join materializes the iterable first ``` `sorted()` cannot rank items it has not seen, so it builds a list internally regardless of how lazily you fed it. Recognising which consumers are streaming and which are buffering is the actual skill here, not the syntax. ## Laziness is also a time property Because elements are produced on demand, a generator expression also defers *work*, not just storage. If the consumer stops early - a `break`, `next()` once, `any()` short-circuiting - the elements after that point are never computed at all. The list comprehension has already paid for all N by the time you look at the first one. That cuts both ways. Side effects inside the expression happen at consumption time, in the consumer's stack, which is why an exception from a generator expression surfaces in the loop that drained it rather than the line that built it. ## What you give up A generator object is an iterator, not a sequence. It has no `__len__` and no `__getitem__`, so `len()`, `gen[3]` and slicing all fail, and it can be walked once. If any of those matter, a list is the correct choice - "generators are better" is not a rule, it is a tradeoff against needing random access, repeat traversal, or a count. Small collections should usually just be lists too. For ten items the memory argument is noise, and the list is easier to print, log and inspect. ## Version notes Both forms are long-standing Python syntax and their semantics are unchanged through 3.14. One relevant modern detail: Python 3.12 (PEP 709) inlined list, dict and set comprehensions so they no longer create a separate function frame per comprehension, which made those noticeably faster; generator expressions were deliberately not inlined, because their whole point is to keep a real, suspendable frame around. So on 3.12 and later a list comprehension over a small collection may well beat a generator expression on speed - the generator's argument is memory and early exit, not raw throughput. ## The interview shape Say it in one breath: the bracket form materializes N objects now; the parenthesis form materializes one at a time on demand; choose the second when the data is large, when you may stop early, or when you are feeding another streaming stage; choose the first when you need length, indexing, or more than one pass.

  • When is a list comprehension the better choice even though it costs more memory?
    When you need the data more than once, need `len()` or indexing, or the collection is small enough that memory is irrelevant. Also when you want the work done now - for example to finish reading a file before its handle is closed, or to make an exception surface at the line that built the data rather than deep inside a later loop.
  • Which consumers cancel the memory advantage of a generator expression?
    Any consumer that must see all elements before producing a result: `sorted()`, `list()`, `set()`, `str.join`, or building a dict from it. They allocate the full collection internally no matter how lazily you fed them. Streaming consumers - `sum()`, `min()`, `max()`, `any()`, `all()`, a plain `for` loop, another generator stage - keep the profile flat.
  • Does a generator expression ever evaluate anything at the moment it is created?
    Yes, exactly one thing: the outermost iterable is evaluated eagerly and bound when the generator object is created. Everything else - the conditions, the output expression, any inner `for` clauses - runs lazily on consumption. That is why `(x for x in undefined_name)` raises immediately while `(1 / 0 for x in [1])` raises only when you advance it.

A list comprehension is printing the whole book before you read page one; a generator expression is a teleprompter feeding you one line as you ask for it.

saying these in an interview costs you the question

  • Calls a generator expression just a faster list comprehension
  • Claims a generator object uses no memory at all
  • Thinks the parenthesized form builds a list first, then streams it
  • Wraps a generator expression in list() and still claims constant memory
  • Believes sorted() over a generator expression stays flat in memory
  • Assumes the list form is always the slower option

context

open as a page

In a chained generator pipeline, when does each stage actually run?

level: middleimportance: should knowfreq 48%

basics

~20 s

Nothing runs until a consumer pulls. Building the chain only creates generator objects; each next() at the end pulls one item backwards through every stage, so the stages interleave item by item rather than each finishing in turn.

open as a page

Why do len() and indexing fail on a Python generator object?

level: middleimportance: should knowfreq 54%

basics

~20 s

A generator object implements only iter and next, with no len and no getitem. It also cannot know how many items remain without running to the end, so the size is unknowable, not merely unimplemented.

open as a page

A 6-hour nightly webhook-log replay dies on memory - how do you restructure it to stream?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Find the stage that materializes - readlines(), a parsed list, a results accumulator, a sort - and make each one a generator stage so a single record flows end to end. Batch with itertools.batched to keep the final short batch.

open as a page