Lazy evaluation

On this page 7

Lazy evaluation is an evaluation strategy that postpones computing an expression until its value is actually demanded, and then remembers the result so the work is never repeated. In functional programming, it allows programs to describe potentially infinite data structures, to compose pipelines without building intermediate collections and to skip computations whose results are never used.

The opposite strategy, eager or strict evaluation, computes every argument before a function body runs, which is the default in most mainstream languages. Haskell is the best-known language that is lazy by default, while languages such as Scala, F#, Clojure, Kotlin and Python offer laziness selectively through streams, sequences, generators and lazy values.

How lazy evaluation works with thunks

A lazy runtime replaces each unevaluated expression with a thunk, a small object that holds the code needed to compute the value and the environment that code refers to. When the program first forces the thunk, for example by pattern matching on it or printing it, the runtime executes the code and overwrites the thunk with its result. Later accesses read the stored value directly.

This combination of delay and caching is called call-by-need. It differs from call-by-name, which also delays evaluation but recomputes the expression every time it is used. Call-by-need is safe only when expressions are free of side effects, because the program cannot predict when or whether a thunk will run. That is why laziness pairs naturally with pure functions and referential transparency.

The table below places the three classic parameter-passing strategies side by side, since lazy evaluation is best understood as one point in this design space.

StrategyWhen arguments are evaluatedRepeated useExample languages
Call-by-valueBefore the function is enteredComputed onceJava, Python, OCaml, Rust
Call-by-nameAt each use inside the bodyRecomputed each timeAlgol 60, Scala by-name parameters
Call-by-needAt first use, then cachedComputed onceHaskell, lazy values in Scala and Kotlin
Evaluation strategies compared

What laziness makes possible

The main benefit of lazy evaluation is separating the description of data from its consumption. A producer can define every prime number or every line of a huge log file, and a consumer decides how much of it to take. In Haskell, take 5 (filter even [1..]) terminates because the list [1..] is materialized only as far as the consumer asks.

  • Infinite structures: streams of events, numbers or game states that are generated on demand.
  • Short-circuiting: operations such as any, find or && stop as soon as the answer is known.
  • Pipeline fusion: chained map, filter and take steps process one element at a time without intermediate lists.
  • Custom control structures: ordinary functions can act like if because unused branches are never evaluated.
  • Memoized definitions: a value defined in terms of itself, such as a dynamic programming table, fills in only the cells that are requested.

Laziness also improves modularity. Without it, a search routine must interleave generating candidates with testing them in a single loop. With it, the generator and the test become independent functions that are composed afterward, and each can be reused or tested alone.

Lazy evaluation across languages

Most languages are strict by default and expose laziness through specific constructs, so the strategy appears in different shapes depending on the ecosystem. The following table summarizes the most common forms.

LanguageLazy constructScope of laziness
HaskellDefault semanticsEvery expression unless forced
Scalalazy val, LazyList, by-name parametersOpt-in per value or collection
Kotlinby lazy, SequenceOpt-in per property or pipeline
PythonGenerators, itertools, generator expressionsIteration only, not cached
JavaStream API, SupplierIntermediate stream operations
C#LINQ with yield return, Lazy<T>Deferred query execution
Typical lazy constructs by language

Note that Python generators and Java streams are single-pass and uncached: iterating twice either fails or recomputes everything, which is closer to call-by-name than to true call-by-need. A Haskell list, by contrast, is shared and evaluated at most once, no matter how many consumers read it.

Costs and the problem of space leaks

Laziness is not free. Each thunk occupies heap memory and adds an indirection, and the runtime must check whether a value has already been computed. In tight numeric loops this overhead can make lazy code several times slower than its strict equivalent, which is why optimizing compilers such as GHC perform strictness analysis to evaluate eagerly wherever the result is certain to be needed.

The more serious risk is the space leak. A classic case is summing a large list with a lazy left fold: instead of a running total, the program builds a chain of millions of unevaluated additions, ((0 + 1) + 2) + ..., and may exhaust memory or the stack before forcing it. The remedy is a strict accumulator, such as foldl' in Haskell, or bang patterns that force values at each step.

SymptomLikely causeTypical remedy
Memory grows steadily, then crashesAccumulated thunks in a foldUse a strict fold or strict fields
File closed before data is readLazy I/O outliving its handleUse streaming libraries or force the content
Unpredictable timing of logsSide effects inside lazy codeKeep effects in the I/O layer
Large structure kept aliveThunk retaining a referenceForce the value or restructure sharing
Hard-to-read profilesCost shifted to the consumerUse cost-center profiling
Common problems with lazy evaluation

Laziness also makes performance harder to reason about, because the cost of an expression is paid wherever it is first forced, which may be far from where it was written. Experienced developers therefore mix strategies deliberately: lazy for structure, strict for accumulation.

When to choose lazy or eager evaluation

The right choice depends on how much of the data will be consumed and how expensive each element is. The decision table below summarizes guidance that holds in most functional code bases.

SituationRecommended strategyReason
Large or unbounded input, partial consumptionLazyAvoids work on unused elements
Numeric accumulation over many itemsStrictPrevents thunk buildup
Expensive value that may never be usedLazy with cachingPays the cost only on demand
Code with logging, I/O or mutationStrictKeeps effects in a predictable order
Small collections used completelyStrictLower overhead, simpler profiling
Choosing an evaluation strategy

Put simply

Lazy evaluation means doing the work only when someone actually needs the answer. Imagine a restaurant that cooks a dish only after a guest orders it, instead of preparing every item on the menu at opening time. Nothing is wasted on dishes nobody wants, and once a dish is ready, a second guest asking for the same plate from the same batch is served immediately.

The catch is that the kitchen must keep track of every pending order. If orders pile up faster than they are cooked, the list itself becomes a problem, which is the software version of running out of memory.

In practice

A data engineer needs the first 20 server log entries that contain a specific error code from a 40 GB archive. An eager script that reads the whole file into a list, filters it and then takes 20 items would need tens of gigabytes of memory and several minutes of reading.

Rewritten as a lazy pipeline, using a generator that yields one line at a time, a filter on the error code and a take of 20 results, the program reads lines only until the twentieth match appears. If the matches occur in the first few megabytes, the job finishes in well under a second with almost constant memory. The engineer also reuses the same generator and filter in another report that counts matches per hour, because the stages are independent functions.