Lazy Evaluation: Deferred Computation
Lazy evaluation is a computation strategy where expressions aren't evaluated until their values are actually required. Instead of computing everything upfront, the runtime creates a promise to...
Key Insights
- Lazy evaluation delays computation until results are actually needed, enabling you to work with infinite data structures and avoid wasting resources on unused calculations.
- While languages like Haskell are lazy by default, most mainstream languages require explicit opt-in through generators, iterators, or manual thunk implementations.
- Laziness isn’t free—space leaks and unpredictable execution order can turn performance gains into debugging nightmares if you don’t understand the trade-offs.
What is Lazy Evaluation?
Lazy evaluation is a computation strategy where expressions aren’t evaluated until their values are actually required. Instead of computing everything upfront, the runtime creates a promise to compute later—and only fulfills that promise when something demands the result.
Consider streaming video versus downloading an entire file. With eager evaluation, you’d wait for the complete download before watching anything. With lazy evaluation, you buffer just enough to start playing, fetching more data as needed. You might never download the ending if you stop watching halfway through.
This distinction matters enormously in software. Eager evaluation computes everything immediately, regardless of whether you’ll use it. Lazy evaluation defers work, potentially avoiding it entirely. When you’re processing a million-row dataset but only need the first ten matches, that difference determines whether your program runs in milliseconds or minutes.
How Lazy Evaluation Works Under the Hood
The fundamental building block of lazy evaluation is the thunk—a suspended computation wrapped in a closure. Instead of storing a computed value, you store a function that will compute the value when called.
Here’s a manual implementation in JavaScript:
function lazy(computation) {
let computed = false;
let value;
return {
force() {
if (!computed) {
value = computation();
computed = true;
}
return value;
}
};
}
// Usage
const expensiveResult = lazy(() => {
console.log("Computing...");
return Array.from({ length: 1000000 }, (_, i) => i * 2);
});
// Nothing computed yet
console.log("Thunk created");
// Now it computes
const data = expensiveResult.force();
console.log(`Got ${data.length} items`);
// Cached—no recomputation
const sameData = expensiveResult.force();
This pattern combines two concepts: deferred execution (the computation doesn’t run until force() is called) and memoization (once computed, the result is cached). The thunk acts as a placeholder that transparently becomes the actual value when needed.
Languages with native lazy evaluation handle this automatically. When you write let x = 1 + 2 in Haskell, the runtime doesn’t immediately compute 3—it creates a thunk. Only when something pattern-matches on x or uses it in I/O does evaluation occur.
Language Support and Syntax
Languages fall into two camps: lazy by default or eager with lazy opt-in.
Haskell is the poster child for pervasive laziness:
-- This creates an infinite list—no problem
naturals :: [Integer]
naturals = [0..]
-- Only computes what's needed
firstTen = take 10 naturals -- [0,1,2,3,4,5,6,7,8,9]
-- Infinite list of Fibonacci numbers
fibs :: [Integer]
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
-- Get the 100th Fibonacci number
fib100 = fibs !! 99
Python uses generators for explicit laziness:
def naturals():
"""Infinite sequence of natural numbers"""
n = 0
while True:
yield n
n += 1
# Create the generator—no computation yet
nums = naturals()
# Pull values on demand
first_ten = [next(nums) for _ in range(10)]
# Generator for Fibonacci sequence
def fibs():
a, b = 0, 1
while True:
yield a
a, b = b, a + b
Rust takes an iterator-based approach with explicit laziness:
fn main() {
// This doesn't compute anything yet
let squares = (0..).map(|x| x * x);
// Only now do we compute—and only 10 values
let first_ten: Vec<i64> = squares.take(10).collect();
// Chained operations remain lazy until collected
let result: Vec<i64> = (0..1_000_000)
.filter(|x| x % 2 == 0)
.map(|x| x * x)
.take(5)
.collect();
}
The key difference: Haskell programmers must think about when to force strictness, while Python and Rust programmers must explicitly request laziness.
Benefits: Performance and Memory Efficiency
Lazy evaluation shines in three scenarios: avoiding unnecessary work, handling unbounded data, and reducing memory pressure.
Consider processing a large log file to find the first error:
# Eager approach—loads entire file into memory
def find_first_error_eager(filename):
with open(filename) as f:
lines = f.readlines() # All 10GB in memory
for line in lines:
if "ERROR" in line:
return line
return None
# Lazy approach—processes line by line
def find_first_error_lazy(filename):
with open(filename) as f:
for line in f: # Generator-based iteration
if "ERROR" in line:
return line
return None
The eager version allocates memory for every line before checking any of them. The lazy version holds one line at a time, stopping immediately when it finds a match. For a 10GB file where the error is on line 5, the difference is catastrophic.
Short-circuit evaluation is lazy evaluation hiding in plain sight:
// The second function never runs if user is null
const name = user && user.getName();
// expensiveCheck() only runs if cheapCheck() passes
if (cheapCheck() && expensiveCheck()) {
doWork();
}
This isn’t just syntactic sugar—it’s the same principle of deferring computation until necessary.
Pitfalls and Debugging Challenges
Laziness has a dark side. The most notorious problem is space leaks—when thunks accumulate faster than they’re evaluated, consuming unbounded memory.
Here’s a classic Haskell example:
-- Space leak: builds massive thunk chain
badSum :: [Int] -> Int
badSum = foldl (+) 0
-- With a million elements, this builds:
-- ((((0 + 1) + 2) + 3) + ... + 1000000)
-- All those additions are deferred!
-- Fixed: force evaluation at each step
goodSum :: [Int] -> Int
goodSum = foldl' (+) 0 -- Note the apostrophe
-- Now each addition happens immediately
The lazy foldl doesn’t compute intermediate sums—it builds a chain of thunks representing deferred additions. With a million elements, you have a million nested thunks before any arithmetic happens. The strict foldl' forces evaluation at each step, maintaining constant memory.
Debugging lazy code is also notoriously difficult. When does computation actually happen? Stack traces point to where evaluation was forced, not where the problematic code was defined. In Haskell, a crash might occur in print while the bug is in a pure function defined elsewhere.
Execution order becomes unpredictable:
def side_effect_generator():
print("Starting")
yield 1
print("After first")
yield 2
print("After second")
yield 3
gen = side_effect_generator()
# Nothing printed yet!
first = next(gen) # Prints "Starting"
second = next(gen) # Prints "After first"
# "After second" never prints if we stop here
If those print statements were database writes or API calls, you’d have partially completed operations scattered unpredictably through your program.
Practical Applications
Lazy evaluation powers many patterns you use daily, even in eager languages.
Query builders defer database execution until results are consumed:
class LazyQuery:
def __init__(self, table):
self.table = table
self.conditions = []
self.limit_val = None
self._executed = False
self._results = None
def where(self, condition):
# Returns new query—doesn't execute
new_query = LazyQuery(self.table)
new_query.conditions = self.conditions + [condition]
new_query.limit_val = self.limit_val
return new_query
def limit(self, n):
new_query = LazyQuery(self.table)
new_query.conditions = self.conditions
new_query.limit_val = n
return new_query
def _execute(self):
if not self._executed:
sql = f"SELECT * FROM {self.table}"
if self.conditions:
sql += " WHERE " + " AND ".join(self.conditions)
if self.limit_val:
sql += f" LIMIT {self.limit_val}"
print(f"Executing: {sql}")
self._results = [] # Would be actual DB results
self._executed = True
return self._results
def __iter__(self):
return iter(self._execute())
# Build query without execution
query = (LazyQuery("users")
.where("active = true")
.where("age > 18")
.limit(10))
# Only now does it hit the database
for user in query:
print(user)
This pattern appears in Django’s ORM, SQLAlchemy, and virtually every modern database library. You compose queries freely, and execution happens only when you iterate or call methods like list() or first().
React’s rendering follows similar principles. Components return descriptions of UI (virtual DOM), not actual DOM operations. React defers and batches actual DOM updates, avoiding unnecessary work when state changes don’t affect visible output.
When to Choose Lazy vs. Eager Evaluation
Use this decision framework:
Choose lazy evaluation when:
- Data size is unknown or potentially infinite
- You frequently need only a subset of results
- Computation is expensive and might be unnecessary
- You’re building composable pipelines
Choose eager evaluation when:
- You need predictable memory usage and timing
- Side effects must occur in a specific order
- Debugging simplicity matters more than performance
- The full result will always be consumed
Hybrid approaches often work best. Rust’s iterator pattern is lazy, but you explicitly .collect() when you want eager evaluation. Python’s itertools provides lazy operations, but you can wrap them in list() for immediate computation.
The pragmatic approach: default to eager evaluation for clarity, introduce laziness where profiling reveals bottlenecks, and always be explicit about evaluation boundaries. Lazy evaluation is a powerful tool, but like all powerful tools, it demands respect for its sharp edges.