Default to Strict Data in Haskell and Opt In to Laziness
Haskell's default laziness stores data fields as thunks. Making data types strict by default with StrictData prevents space leaks from thunk buildup, while selective laziness preserves composability where it matters.
26 Dec 2025, 02:06 UTC

Haskell programs can run out of memory while doing trivial work. The usual culprit is not an algorithm, it is a chain of unevaluated thunks accumulating inside a data structure. The practical engineering response is to make data types strict by default and reintroduce laziness only where it buys you composability.
The problem you feel in production
GHC evaluates expressions lazily. A field in a data constructor is stored as a thunk, a promise to compute the value later. That is fine until you build a long chain of thunks that is never forced in a controlled way.
A classic sign is a left fold that looks correct but grows memory linearly. With a lazy accumulator the expression `acc + x` is not reduced, it is wrapped in a new thunk that refers to the previous thunk. The whole chain is kept alive until the final result is demanded, which is a space leak.
The takeaway is not to abandon laziness. It is to stop paying for it implicitly in data types that are meant to hold concrete values.
StrictData and BangPatterns as control points
StrictData is a GHC language pragma that makes all fields of a data type strict by default. Strictness here means the field is evaluated to weak head normal form when the constructor is built, not later.
{-# LANGUAGE StrictData #-}
data Point = Point
{ px :: Int
, py :: Int
} deriving Show
With StrictData enabled, `Point 1 2` forces both `1` and `2` immediately. Without it, both fields are thunks until accessed.
BangPatterns give field-level control without changing the default. A `!` before a field forces it:
data Acc = Acc { !total :: Int, items :: [Int] }
Function arguments are separate. StrictData does not make arguments strict. For arguments you use BangPatterns on the pattern or `seq`/`deepseq` via NFData to force evaluation at a chosen point.
Verification in GHCi is direct. Load a module and use :sprint to see whether a value is a thunk or evaluated:
ghci> :load Main
ghci> let p = Point 1 2
ghci> :sprint p
You can also compare residency with RTS flags. Compile a small program with and without StrictData and run it with +RTS -s and -T. Run from a terminal in the project directory with normal user permissions; no elevated rights are needed. Look for differences in maximum residency and bytes allocated. This is a diagnostic check, not a benchmark.
Worked example: strict accumulator
Consider a simple sum over a list. The lazy fold is:
import Data.List
lazySum xs = foldl (+) 0 xs
The strict variant forces the accumulator each step:
strictSum xs = foldl' (+) 0 xs
foldl' is the strict left fold from Data.List. It uses seq to force the accumulator before recursing.
Now put the accumulator in a record. With StrictData:
{-# LANGUAGE StrictData #-}
data SumState = SumState { total :: !Int, rest :: [Int] }
Without StrictData you would need BangPatterns on every field you want strict, which is tedious and error prone in large code bases.
Build and run locally:
ghc -O2 Main.hs -o main
./main +RTS -s -RTS
Expected checks are qualitative: the strict version should avoid building a long thunk chain for the accumulator, which tends to lower live bytes and maximum residency for large inputs. Do not assume a specific number; compare the two builds under the same input.
Risks: forcing too early can increase work if the value is never used, and strictness changes semantics with respect to non-termination. A lazy field that would never be demanded can cause divergence when made strict.
When laziness is still the right tool
Laziness enables demand-driven evaluation, infinite lists, and fusion-style pipelines. Keep fields lazy for intentionally deferred computations, e.g., a field that holds a large derived structure used only in some code paths.
Selective laziness is the pattern: default strict for data, and make specific fields lazy with ~:
data Cache = Cache
{ key :: !Text
, ~value :: ByteString
}
The tilde makes value lazy even under StrictData.
Limitations and a practical check
StrictData applies to data constructor fields, not to let-bound expressions or function arguments. Overly strict code can break fusion and force work that lazy pipelines would skip.
A practical way to check is to compile two variants of a module, one with StrictData and one without, and inspect with :sprint and RTS stats on a representative input. Test edge cases with infinite data and cyclic structures; strict evaluation may diverge where lazy evaluation succeeds.
Default strict, measure, and opt in to laziness where composability justifies it.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.