Lazy Evaluation and Memory Efficiency
Overview
Lazy evaluation is a strategy where an expression is not computed until its value is strictly needed. Instead of materializing an entire sequence in memory at once (like a list), Python can yield one item at a time upon request. nums = [1, 2, 3, 4, 5] lazy = (n * n for n in nums) # nothing computed yet print(next(lazy)) # 1 -- only the first square is computed print(next(lazy)) # 4 -- computed on demand, one at a time This acts like a streaming pipeline: `list(x for x in huge_file)` would load everything into memory at once, while iterating the generator directly processes one item, uses it, and discards it before pulling the next. The mental model: computing a result 'just in time' rather than 'all in advance', reducing both initial startup time and total memory consumption.
It prevents memory exhaustion when processing massive datasets (e.g., parsing a 50GB log file or querying millions of database rows) and allows processing to begin immediately without waiting for the full dataset to load.
Where used: Pandas chunking, Django QuerySets, SQLAlchemy cursors, File reading
Why learn this
- Processing files larger than available RAM
- Writing high-performance API endpoints that stream data to clients
- Optimizing database query iterators to fetch rows in batches
Code walkthrough
def process():
print('Computing...')
return 42
lazy_result = (process() for _ in range(2))
print('Created')
first = next(lazy_result)
print('Done')
Focus: first = next(lazy_result)
Aha moment
nums = [1, 2, 3]
lazy_squares = (n * n for n in nums)
nums.clear()
print(next(lazy_squares, 'Empty'))
Prediction: What will be printed when we call `next()` on the generator expression after clearing the source list?
Common guess: 1
The generator expression evaluates lazily and fetches from the `nums` list when `next()` is called. Since the list was cleared beforehand, it yields the default 'Empty'.
Common mistakes
- Exhausting iterators silently: Because lazy iterators (like generators) compute items on-the-fly and don't store them, they can only be iterated over once. Iterating a second time yields nothing. (Note that some lazy sequences, like range(), are an exception and can be re-iterated).
- Accidentally materializing to a list: Calling `list()` on a lazy iterator immediately evaluates all elements, pulling everything into memory and completely defeating the purpose of lazy evaluation.
Recall questions
- How does lazy evaluation reduce memory consumption compared to eager evaluation?
- What happens if you try to iterate over a lazy generator expression twice?
- Why is it dangerous to call `list()` on a lazy iterator when processing a massive log file?
Understanding checks
Why would you choose a generator expression over a list comprehension when reading a massive log file line-by-line?
A generator expression yields one line at a time on demand, so it uses almost no memory no matter how large the file is. A list comprehension loads every line into memory at once, which can crash the program if the file is larger than available RAM.
A list comprehension materializes all items in memory at once, which could crash your program if the file is larger than available RAM. A generator expression evaluates lazily, yielding one line at a time and maintaining a low memory footprint regardless of the file size.
What happens when this code is executed?
[1, 2] []
Lazy iterators, like the one returned by a generator function, compute items on the fly and do not store them. Once exhausted by the first `list()` call, the iterator is empty, so the second call yields an empty list.
Practice tasks
Processing a large stream lazily
The `print_errors` function currently converts the `logs` generator into a list, which eagerly processes all entries and can cause a memory crash. Modify the function to process the `logs` lazily.
Challenge
Lazy batch filtering
You are given an iterator `records` that yields thousands of dictionaries representing API payloads. Each payload has a 'size' key. Create a lazy generator expression `large_records` that yields only the payloads where 'size' > 1000. Then use `next()` to return the very first large payload without processing the rest of the stream. Return `None` if no such payload exists.
Continue learning
Previous: Generator expressions vs list comprehensions
Previous: Iterator Protocol and StopIteration