Skip to content
QSWEQB
mediumConceptCodingMidSeniorStaff

How do generators reduce memory use, and when are they the wrong choice?

A generator produces items on demand and keeps only the current item plus its suspended frame, so memory stays flat as the input grows; it is the wrong tool when you need a length, indexing, or more than one pass, and when per-item resume overhead outweighs the space saved.

4 min readUpdated 2026-07-26

What the interviewer is scoring

  • Does the candidate explain the saving as "one item at a time plus a suspended frame" rather than vaguely as "generators are efficient"
  • Whether single-pass exhaustion is stated as a property of the protocol, not discovered as a surprise
  • That they connect the missing len and indexing to the absence of __len__ and __getitem__ rather than calling it a language limitation
  • Whether the candidate volunteers that a generator body does not run at all until it is first advanced, so validation and cleanup are deferred
  • Does the candidate name a case where a list is the better answer, instead of treating laziness as always superior

Answer

Where the memory goes instead

A list comprehension builds every element before the first one is used. A generator computes an element, hands it to the consumer, and suspends. What stays alive is the item in flight plus one paused frame holding the generator's locals and its instruction pointer. That frame does not grow with the length of the stream, so peak memory is a function of how large a single item is and how deep the pipeline is, not of how many items pass through it.

You can size the difference from first principles. A list of ten million elements needs a contiguous array of ten million pointers, and a pointer is eight bytes on a 64-bit build, so the array alone is eighty megabytes before you count the objects it points at. The generator equivalent allocates one object at a time and lets each become garbage as the consumer moves on, so resident memory is flat whether the source has ten thousand rows or ten billion. That is the case for streaming a file line by line, reading rows off a cursor, or paginating an API. It is also why the saving evaporates when a single item is the problem: a CSV row carrying a fifty-megabyte blob still puts fifty megabytes in memory, and a generator that internally accumulates a dict of everything it has seen has the memory profile of the dict.

One pass, no length, no index

A generator is an iterator, and that protocol offers one operation: give me the next item. Everything a sequence supports beyond that is absent because the corresponding dunder methods are absent.

squares = (n * n for n in range(5))

sum(squares)        # 30
sum(squares)        # 0  - already exhausted, next() raises StopIteration immediately
len(squares)        # TypeError: no __len__, and the length is unknowable without consuming
squares[2]          # TypeError: not subscriptable, there is no __getitem__

The second sum returning zero rather than raising is what bites in production. An exhausted generator is a valid empty iterable, so a second pass silently produces nothing: a function that iterates its argument twice, once to validate and once to process, reports every record as valid and then writes none of them. A consumer needing two passes, a length, sorting, or random access needs a materialised sequence, and sorted() or list() builds one anyway. Accept the list openly rather than paying for laziness and then destroying it.

Nothing runs until you advance it

Calling a generator function executes none of its body. It builds a generator object and returns. The first next() runs up to the first yield, and each subsequent next() resumes from there. So an argument check at the top of a generator function does not fire when the caller calls it, it fires whenever iteration starts, possibly in another module, inside a try written to catch something else. The fix is a thin eager wrapper that validates and returns the inner generator. Generator expressions differ: the outermost iterable is evaluated at creation, so (x for x in None) raises straight away.

The same deferral governs cleanup, and this is what separates a strong answer. Code after a yield runs only if the generator is resumed past that point, so a with block inside a generator releases nothing while the generator sits suspended.

def rows(path):
    with open(path) as fh:        # the file is open from the first next() onwards
        for line in fh:
            yield line.strip()
        # closing happens only when control returns here

it = rows("big.csv")
next(it)                          # opens the file, yields one row
it.close()                        # throws GeneratorExit at the paused yield
                                  # -> the with block unwinds and the file closes

close() raises GeneratorExit at the suspension point, and because it propagates like any exception it runs finally blocks and context-manager exits on the way out. A for loop that ends early does not call it. CPython closes a generator when the object is collected, but that timing depends on reference counting, is delayed by cycles, and is not something to rely on across implementations; when it matters, force it with with contextlib.closing(gen):. Do not try to keep going after GeneratorExit - yielding again from a generator being closed raises RuntimeError. This same mechanic is what contextlib.contextmanager is built on: setup runs on the way to the yield, and the teardown after it runs because __exit__ advances the generator once more.

The cost you are paying

Per item, a generator is slower than the equivalent list build. Every next() is a frame resume and a bytecode dispatch round trip, with an exception raise at the end, whereas a comprehension runs its loop inside one frame. For a tight numeric loop over data that comfortably fits in memory the list wins on wall clock and the generator wins nothing worth having. Laziness pays when the alternative is materialising something large, or when the consumer stops early and the unused work is never done at all.

The other cost is architectural. A generator is not picklable, so it cannot cross a multiprocessing boundary and cannot be cached; you pass the parameters and rebuild it on the far side. itertools.tee gives you two independent readers, but it buffers everything one has consumed and the other has not, so consumers moving at different speeds make it quietly worse than the list you were avoiding.

Laziness does not remove work or errors, it relocates them to whoever iterates. Reach for a generator when the input is larger than memory or may not be consumed in full, and reach for a list the moment you need a length, an index, or a second pass.

Likely follow-ups

  • What happens to a with block inside a generator if the consumer stops iterating half way through?
  • Why can a generator not be sent to a worker with multiprocessing, and what do you do instead?
  • When is itertools.tee worse for memory than simply building a list?
  • How does contextlib.contextmanager use the fact that a generator suspends at yield?

Related questions

Further reading

generatorsiteratorslazinessmemorycontextlib