Haskell World logo Haskell WorldWrite code, build worlds
Programming

Lazy Evaluation in Haskell and Why It Actually Matters

Lazy evaluation is one of Haskell's most distinctive features and one of its most misunderstood. It is variously blamed for mysterious space leaks, credited with enabling infinite data structures, and explained with increasingly elaborate...

Lazy evaluation in Haskell and why it matters for developers

Lazy evaluation is one of Haskell's most distinctive features and one of its most misunderstood. It is variously blamed for mysterious space leaks, credited with enabling infinite data structures, and explained with increasingly elaborate analogies that sometimes help and sometimes make things more confusing. A grounded explanation of what lazy evaluation actually is, how GHC implements it, and where it has genuine consequences for the code you write is worth having.

What lazy means, precisely

In a lazily evaluated language, an expression is not evaluated when it is defined - it is evaluated when its value is needed. "Needed" means being demanded by a context that cannot proceed without a concrete value: printing it, comparing it, using it in an arithmetic operation. Until that demand arrives, the expression exists as a thunk - a closure containing the computation and its captured environment.

In an eagerly evaluated language, the arguments to a function are evaluated before the function is called. In Haskell, they are not - they are passed as thunks and evaluated only if the function actually needs their values. If a function discards an argument without examining it, the argument is never evaluated at all. This is the core of lazy evaluation, and its implications are both useful and occasionally surprising.

The formal property that this enables is called non-strict semantics. A function f is strict if f bottom = bottom, where bottom represents a non-terminating computation or an exception. A strict function always evaluates its argument; if the argument does not terminate, neither does the function. A non-strict function does not necessarily evaluate its argument; it might return a result without ever examining the argument. Lazy evaluation is the standard implementation strategy for non-strict semantics.

Infinite data structures

The most often-cited benefit of lazy evaluation is the ability to define and work with infinite data structures. The infinite list of all natural numbers is written nats = [0..] in Haskell. This does not allocate an infinite list - it allocates a thunk that, when forced, produces 0 and another thunk for the rest. When you take the first ten elements with take 10 nats, only those ten elements are computed. The rest remains unevaluated.

This enables a programming style where you define a complete sequence and then take the part you need, rather than computing exactly as many elements as you need. The Fibonacci sequence defined as a lazy list: fibs = 0 : 1 : zipWith (+) fibs (tail fibs). This definition is circular - fibs refers to itself in its own definition - and it works because laziness prevents the circular reference from causing infinite recursion. Computing the first n Fibonacci numbers is just take n fibs.

Infinite data structures are useful beyond this kind of definition. A stream of input events, a sequence of server-generated IDs, a lazy list of file lines - all of these are naturally infinite or unbounded and benefit from being represented as lazy sequences. The consumer of the sequence controls how much of it is evaluated, and the generator does not need to know in advance how much will be demanded.

Laziness and modularity

John Hughes' classic paper "Why Functional Programming Matters" makes the argument that lazy evaluation enables a specific kind of modularity: the separation of how much to compute from what to compute. A generator function produces values; a consumer function determines how many values are needed. With eager evaluation, these concerns are entangled. With lazy evaluation, they compose freely.

The practical application is in pipeline processing. A function that generates candidates, a function that filters them, and a function that takes the first valid one compose cleanly with lazy evaluation. The generation happens only as far as needed to produce enough valid candidates. With eager evaluation, you need to know in advance how many candidates to generate, or generate all of them and waste work on candidates that were never examined.

This composability shows up in real code when working with large data sets. A lazy read of a large file, filtered for lines matching a pattern, with only the first match returned - each of these steps is a function transformation, and with lazy evaluation, the combination reads only as much of the file as is needed to find the first match. The modular composition of the three operations produces the correct efficient behavior automatically.

Space leaks: the dark side of laziness

Lazy evaluation's downside is space leaks. When a computation that would produce a value is deferred, it is stored as a thunk. If many thunks accumulate before they are forced, the heap grows. If the accumulated thunks form a long chain where each one depends on the previous, forcing the final thunk requires keeping all intermediate thunks alive until they are processed. This is a space leak: more memory is used than a strict evaluation would require.

The canonical example is a strict left fold written lazily. The function foldl (+) 0 [1..1000000] builds up a chain of one million unevaluated additions before computing the final result, requiring O(n) memory for what should be an O(1) space operation. The solution is foldl', the strict version of foldl from Data.List, which forces each accumulator value before proceeding to the next. Using foldl' instead of foldl in reduction operations is one of the most common correctness fixes in Haskell code.

More subtle space leaks arise from retaining references to data that is no longer needed. If a function that processes a long list holds a reference to the head of the list while working on the tail, the garbage collector cannot reclaim the head. Recognizing this pattern and restructuring code to release references early is part of writing Haskell that does not leak memory.

Debugging space leaks

Space leaks are harder to debug than other bugs because they manifest as memory growth rather than incorrect output. A program that produces correct results but uses increasing amounts of memory over time likely has a space leak. The debugging approach is to profile heap usage with GHC's profiling tools, identify which data is being retained, and trace back to the code that is preventing it from being collected.

GHC's profiling flags (-prof and -fprof-auto) instrument the program to record cost center information. Running the profiled program with +RTS -hc produces a heap profile that shows which cost centers are responsible for live heap at each point in time. The hp2ps tool converts the profile to a visualization showing heap growth over time. A space leak appears as a cost center whose heap contribution grows continuously rather than stabilizing.

The fix is usually one of a small set of patterns: replacing lazy folds with strict folds, adding bang patterns (!) to force strict evaluation of accumulator values, using seq to force specific values before they are added to a data structure, or restructuring code to avoid retaining references longer than necessary. Each fix addresses a specific mechanism by which laziness causes accumulation.

Controlling evaluation with strictness annotations

Haskell provides tools for controlling evaluation strictness when the default lazy behavior is inappropriate. Bang patterns force a value to be evaluated to weak head normal form (WHNF) when it is bound: let !x = expensive_computation forces x to be evaluated immediately rather than deferred. The BangPatterns language extension is required to enable this syntax.

The strict Data.Map.Strict and Data.Sequence.Strict variants force values to WHNF when they are inserted, preventing the accumulation of unevaluated thunks inside data structures. Using these strict variants in place of the lazy defaults is common when building data structures through repeated insertion, where the lazy variants would accumulate thunks for each inserted value.

The DeepSeq library provides rnf (reduce to normal form), which forces a value and all values it contains to be fully evaluated. This is sometimes needed at boundaries between lazy and strict code, where you need to ensure that a value is fully computed before passing it to strict code. Using rnf requires a deepseq typeclass instance for your types, but the library provides generic deriving so this is usually a one-line addition to a type definition.

Laziness in practice: a balanced view

Laziness is not a feature to work around - it is a feature to understand well enough to use correctly. The benefits - infinite data structures, composable pipelines, efficient short-circuiting - are genuine and appear regularly in well-written Haskell code. The costs - space leaks, non-obvious evaluation order, occasional performance surprises - are real but manageable with experience.

The practical advice for working with Haskell laziness is: use strict variants of folds and accumulators by default, add strictness annotations when profiling identifies space leaks, and rely on the lazy behavior when defining generators and pipelines where it is genuinely useful. This approach takes advantage of laziness where it helps and avoids its pitfalls where they are common.

AS
Adil Sato

Adil Sato teaches programming concepts at a community college and writes online guides for the questions students ask most in the second week of class, not the first. His focus is on making type systems, monads, and functional patterns understandable without reducing them to metaphors that break down the moment you try to use them for real work.

More posts by Adil

More from the blog