Lazy Evaluation
Haskell evaluates expressions only when their values are actually needed. This single design choice shapes the language more than any other. It's why you can write let xs = [1..] without your program hanging, why if is a function instead of a keyword, why || and && short-circuit without being special-cased, and why a "trivial" function like foldl (+) 0 can leak memory on a large list. Laziness gives you tremendous expressive power and a specific category of bugs that no strict language has.
Call-by-Need
Haskell uses call-by-need evaluation. When you bind a value, GHC creates a thunk: a deferred computation that, when forced, produces a value and replaces the thunk with that value. The next time you need the same expression, the cached value is returned.
let x = 1 + 2 + 3
in x + x
If Haskell were call-by-name, 1 + 2 + 3 would be evaluated twice. Under call-by-need, it's evaluated once, the thunk is updated to hold 6, and the second x reads the cached value.
This is different from call-by-value (strict languages like Java or Rust, which evaluate 1 + 2 + 3 immediately at the binding) and call-by-name (Haskell's pure-laziness theoretical cousin, which would re-evaluate on every use). The "need" in call-by-need is what gives you sharing.
You can see this in action with Debug.Trace:
import Debug.Trace (trace)
main :: IO ()
main = do
let x = trace "computing x" (1 + 2 + 3)
print x
print x
Output:
computing x
6
6
The trace fires once. The first print x forces the thunk; the second sees the cached value.
Thunks
A thunk is a heap object that holds an unevaluated expression plus references to anything it captures. Every binding in Haskell starts as a thunk unless something forces it.
main :: IO ()
main = do
let big = sum [1..1000000] -- thunk created, not evaluated
putStrLn "before"
print big -- thunk forced here, evaluation happens
putStrLn "after"
Between "before" and the print, the work hasn't happened yet. big is just a pointer to "the unevaluated sum [1..1000000]."
Thunks are cheap to create but not free to keep around. A thunk for 1 + 1 takes more memory than the integer 2. This is the seed of the space-leak problem we'll dig into in the next pages.
What Laziness Enables
Infinite Lists
The poster child:
naturals :: [Int]
naturals = [1..]
main :: IO ()
main = print (take 10 naturals)
-- [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
naturals is conceptually infinite. The expression doesn't try to compute the whole list because nothing is forcing it. take 10 only forces ten cons cells. The rest of the list never exists.
This unlocks elegant solutions to problems that would require explicit state in strict languages:
-- Fibonacci as a recursive infinite list
fibs :: [Int]
fibs = 0 : 1 : zipWith (+) fibs (tail fibs)
main = print (take 15 fibs)
-- [0,1,1,2,3,5,8,13,21,34,55,89,144,233,377]
fibs references itself. Strict evaluation would loop forever; lazy evaluation builds each cell on demand and reuses the already-computed prefix.
A practical example: prime numbers via the sieve of Eratosthenes.
primes :: [Int]
primes = sieve [2..]
where sieve (p:xs) = p : sieve [x | x <- xs, x `mod` p /= 0]
main = print (take 20 primes)
This isn't the most efficient sieve, but the point is that you describe what primes are and let laziness produce them on demand.
Naturally Short-Circuiting Operators
In most languages, && and || are special syntax because they need to skip evaluation of the right operand. In Haskell, they're ordinary functions:
(&&) :: Bool -> Bool -> Bool
True && y = y
False && _ = False
(||) :: Bool -> Bool -> Bool
True || _ = True
False || y = y
Because the second argument is a thunk and only forced when needed, False && undefined returns False without ever touching undefined. The same pattern works for if:
myIf :: Bool -> a -> a -> a
myIf True t _ = t
myIf False _ f = f
-- This is fine because only one of the branches is forced
result = myIf (x > 0) (sqrt x) 0
In a strict language, sqrt x would be evaluated even if x were negative. In Haskell, only the chosen branch runs.
Decoupling Producer and Consumer
Laziness lets you write the producer and the consumer separately and combine them without intermediate storage. This is the basis of streaming-style code:
import Data.List (sort)
-- Find the 10 smallest elements of a million-element list
top10 :: [Int] -> [Int]
top10 = take 10 . sort
sort is a full sort, but because of laziness, GHC only does enough work to produce the first 10 elements. You don't pay for sorting the whole list. This isn't quite true for every sorting implementation, but it's the principle: lazy pipelines compose without intermediate allocations.
How Laziness Surprises You
The same property that makes infinite lists work also creates the most distinctive class of Haskell bugs: space leaks.
A space leak happens when thunks accumulate faster than they're forced. Consider this innocent-looking accumulator:
import Data.List (foldl)
sumDangerous :: [Int] -> Int
sumDangerous = foldl (+) 0
main = print (sumDangerous [1..10000000])
This blows the stack. Why? foldl (+) 0 [1, 2, 3] becomes ((0 + 1) + 2) + 3, but each + is itself a thunk. By the time we've walked the entire list, we have a chain of ten million + thunks, all pointing at each other. When print finally demands the result, GHC has to evaluate the whole chain, recursing into each thunk in turn — and the stack runs out.
The fix: force the accumulator at each step. We'll cover the tools (seq, bang patterns, foldl') in the next page. The point here is that lazy evaluation is not free. It introduces a runtime cost in thunk allocation and a correctness risk in unbounded thunk chains.
A more subtle leak:
-- A long-running event loop
loop :: Int -> IO ()
loop !state = do
event <- waitForEvent
let newState = state + processEvent event
loop newState
If state were lazy (no bang pattern), each iteration would build processEvent event_n + (processEvent event_n_minus_1 + ...). The "current state" is really a thunk chain growing forever. Adding !state forces it on each iteration.
The Mercury team has written extensively about hunting space leaks in production Haskell servers; Facebook's Sigma team has done the same. They're a real concern, not a theoretical one.
Forcing Evaluation
Sometimes you want a value computed now. The basic tool is seq:
seq :: a -> b -> b
seq x y forces x to weak head normal form (WHNF — the outermost constructor or lambda) and returns y. The result is y, but with the side effect that x has been evaluated.
let result = expensiveComputation
in result `seq` continueWithSomething result
This is the building block. The BangPatterns extension provides nicer syntax:
{-# LANGUAGE BangPatterns #-}
go :: Int -> [Int] -> Int
go !acc [] = acc
go !acc (x:xs) = go (acc + x) xs
The ! says "force this argument when the function is called." We'll cover this in detail in the strictness page.
A Note on WHNF
Forcing a value to WHNF means evaluating it just enough to see the outermost constructor. It does not force everything inside.
let xs = [1 + 1, 2 + 2, 3 + 3] :: [Int]
in xs `seq` () -- forces the (:) constructor
-- but 1 + 1, 2 + 2, 3 + 3 remain thunks
For records or tuples, WHNF gives you the constructor, not the fields. This is why "I added seq" sometimes doesn't fix the leak — you forced the spine but not the contents. Tools like deepseq (force and NFData) recursively force everything; we'll see them in the next pages.
Common Pitfalls
Assuming "let" computes the value. let x = expensive creates a thunk. The expensive computation hasn't happened. If you wanted it computed now, use let !x = expensive (with BangPatterns) or x seq rest.
Confusing seq with deep evaluation. seq only forces to WHNF. A list seq-forced is a list with its first cons cell evaluated, not a fully evaluated list.
Treating laziness as an optimization. Laziness is part of Haskell's semantics. You can't "turn it off" except for specific values. The GHC -XStrict extension turns on strictness by default, but it changes behavior in ways that surprise existing code.
Chasing performance with strictness everywhere. Sprinkling ! and seq randomly often makes code slower (forcing values that didn't need it) without fixing actual leaks. Profile first.
Forgetting that IO is strict in its sequencing but lazy in its values. Inside a do block, the actions run in order, but the values they bind are still thunks. let x = expensiveComputation inside IO doesn't compute x until you use it.
Key Takeaways
Haskell uses call-by-need evaluation: expressions become thunks that are forced and cached when their values are needed. This enables infinite data structures, naturally short-circuiting boolean operators and if, and decoupled producer/consumer pipelines. The cost is that thunks can pile up and cause space leaks, especially in accumulators and long-running loops. The next two pages cover the tools to manage this: strictness annotations, seq, bang patterns, strict variants of standard data types, and how to find space leaks in real code.