Python Eager vs Lazy Evaluation: What Actually Runs
python eager vs lazy evaluation: Python evaluates expressions eagerly by default; lazy evaluation via generators, iterators, and built-ins changes memory and runtime b...
In Python, most expressions are evaluated eagerly: the moment you write [x * 2 for x in range(10)], the list is built in full before the next line runs. But Python also provides lazy evaluation through generators, iterators, and several built-in functions. Understanding python eager vs lazy evaluation matters when you need to control memory usage, avoid repeated computation, or process data streams that do not fit in memory.
What Eager Evaluation Means in Python
Eager evaluation means an expression is computed as soon as it is bound to a name or passed to a function. Consider:
numbers = [x * 2 for x in range(1000)]
The list comprehension builds all 1000 elements immediately. numbers is a complete list in memory, and you can index into it, slice it, and iterate over it repeatedly.
Most Python code is eager by default:
- Function arguments are evaluated before the call
- List, dict, and set comprehensions build complete containers
- Arithmetic and string operations produce results immediately
sorted(),sum(),min(), andmax()consume their input fully
Eager evaluation is predictable. When a line finishes, the result is available and stable. This is usually what you want for small data, configuration values, or any computation you need to reuse.
Where Lazy Evaluation Enters Python
Lazy evaluation delays computation until a value is actually requested. In Python, the main mechanisms are:
- Generator functions using
yield - Generator expressions
- Iterators returned by built-ins like
map(),filter(),zip(), andenumerate() - The
range()object, which is not a list
A generator function does not run its body when called:
def read_lines(path): with open(path) as f: for line in f: yield line.strip()
Calling read_lines("data.txt") returns a generator object immediately. The file is not opened, and no line is read, until you call next() on the generator or iterate over it.
The same principle applies to generator expressions:
squares = (x * x for x in range(1000))
(x * x for x in range(1000)) creates a generator object without computing a single square. Only when you iterate does each value get produced, one at a time.
How Built-in Functions Differ Between Versions
In Python 2, map(), filter(), and zip() returned lists. In Python 3, they return iterators. This behavioral difference affects code written for both versions.
result = map(str.upper, ["a", "b", "c"])
In Python 3, result is a map object. It is single-use: iterating over it once consumes it. To get a reusable list, you must explicitly materialize it:
result_list = list(map(str.upper, ["a", "b", "c"]))
The same applies to filter() and zip(). If your code assumes these functions return lists, you need to wrap them in list() when the result must be indexed, sliced, or iterated more than once.
range() and the Difference from a List
range() is lazy in the sense that it does not allocate all integers up front:
r = range(10**9)
r occupies a small, fixed amount of memory regardless of the upper bound. It supports indexing and membership testing without materializing the full sequence. But range is not a generator: it is a sequence type that can be iterated repeatedly and supports len(), in, and indexing.
len(r) # works 999_999_999 in r # works, and is fast r[5] # works
This makes range a better choice than list(range(...)) whenever you only need to iterate or index without storing every value.
Memory and Runtime Tradeoffs
The most visible difference between eager and lazy evaluation is memory behavior.
An eager list holds every element:
data = [line.strip() for line in open("large.log")]
If the file has a million lines, the list holds a million strings. The full content lives in memory after the comprehension finishes.
A lazy approach processes one line at a time:
def clean_lines(path): with open(path) as f: for line in f: yield line.strip()
The generator yields one string at a time. Memory usage stays roughly constant regardless of file size. This is the primary reason to prefer lazy evaluation for large inputs.
The runtime tradeoff is more subtle. Lazy evaluation can avoid work that is never needed. If you only need the first five results from an expensive computation, a generator computes five values; an eager list computes all of them:
def expensive(n): print(f"computing {n}") return n * n first_five = [expensive(i) for i in range(100)][:5] # computes 100 values first_five_lazy = list(expensive(i) for i in range(100))[:5] # still computes 100 values because list() consumes the generator
To actually avoid the extra work, you must stop iterating early:
gen = (expensive(i) for i in range(100)) first_five = [next(gen) for _ in range(5)]
This computes only five values. Laziness only saves work if you stop requesting values. If you always consume the entire generator, the total work is the same, and the generator adds a small per-item overhead.
| Property | Eager (list) | Lazy (generator) |
|---|---|---|
| Memory | Full container in memory | One value at a time |
| Reusability | Iterate repeatedly | Single-use |
| Indexing | Supported | Not supported |
| Error timing | At construction | At iteration |
| Early exit | Wasted work | Avoids remaining work |
Choosing Between Eager and Lazy
The decision depends on what you need to do with the data after it is produced.
Use eager evaluation when:
- The result must be indexed, sliced, or reused multiple times
- The data set is small enough that memory is not a concern
- You need a stable snapshot of values, not a stream
- The computation is cheap and the result is used immediately
Use lazy evaluation when:
- The input is large or unbounded, such as log files, network streams, or sensor data
- You may stop early after finding a match
- You want to chain transformations without building intermediate lists
- The values are produced by a computation you may not need
A common pattern is to keep the pipeline lazy and materialize only at the end:
lines = (line.strip() for line in open("data.txt")) non_empty = (line for line in lines if line) parsed = (parse(line) for line in non_empty) results = list(parsed)
Each generator in this chain is lazy. No line is read or parsed until list(parsed) pulls values through the entire pipeline. Intermediate lists are never created.
Common Pitfalls with Lazy Evaluation
Lazy evaluation changes behavior in ways that surprise developers coming from eager languages.
Generators are single-use. Iterating a generator exhausts it. A second loop produces nothing:
gen = (x for x in range(5)) list(gen) # [0, 1, 2, 3, 4] list(gen) # []
If you need to iterate twice, materialize the values into a list or recreate the generator.
Errors surface at iteration time, not creation time. A generator function's body runs only when iterated. An exception raised inside the body appears when you call next(), not when you create the generator. This separates the failure point from the call site, which can make debugging harder.
Lazy chains hide cost until consumed. A pipeline of generators looks cheap because no work happens immediately. But when the final consumer pulls values, the full chain runs. If the consumer is slow or the chain is deep, the cost appears at the consumption point, not at the construction point.
len() and indexing do not work on generators. A generator has no length and no index. If your code needs either, convert to a list or use a sequence type like range or a list comprehension.
Practical Pattern: Streaming File Processing
A realistic use of lazy evaluation is processing a file without loading it entirely:
def parse_events(path): with open(path) as f: for line in f: if not line.strip(): continue yield parse_event(line) def parse_event(line): # returns a dict or None ... for event in parse_events("events.log"): if event and event["severity"] == "error": handle_error(event)
The file is read line by line. parse_event runs only for lines that are not blank, and only as many lines as needed until the loop ends. If the loop breaks early, no further lines are read.
This pattern keeps memory flat and avoids parsing data that is never used. The cost is that you cannot rewind the file or revisit an earlier event without reopening it.