StrictData in Haskell: Flip the Default to Avoid Space Leaks and Boost Performance
StrictData flips Haskell’s default to strict fields, preventing space leaks and enabling unboxing. Learn when to use it, how to migrate modules, and the trade‑offs involved in this practical engineering decision.
12 Sept 2025, 15:23 UTC

Why StrictData Matters
In Haskell, most data constructors are lazy by default. This means that the values stored in fields aren’t evaluated until they are actually needed. While laziness is powerful, it can also lead to subtle memory leaks when large structures are built and never fully consumed. The StrictData language extension flips the default: all fields in algebraic data types become strict, i.e. evaluated to weak head normal form (WHNF) at construction time.
Strictness is a practical engineering decision. It trades a small upfront evaluation cost for predictable memory usage and often exposes errors (bottoms) earlier. The extension is available from GHC 8.0 onwards and is enabled per‑module, so you can target only performance‑critical parts of a codebase.
When to Use Strict vs. Lazy Fields
A field is lazy when the constructor stores a thunk that will be evaluated later. A strict field forces the argument to WHNF as soon as the constructor runs. The decision depends on:
- Memory safety: If a record holds a large list that will never be traversed, keeping it lazy can cause the entire list to stay in memory.
- Computation cost: If evaluating a field is expensive and the value may be discarded, strictness forces unnecessary work.
- Recursion and infinite data: Lazy fields are essential for streams, cyclic data structures, or parsers that build recursive ASTs.
- Sharing: Strict fields break sharing of unevaluated thunks; identical computations will be recomputed unless explicitly shared.
StrictData in Action: A Concrete Example
Consider a module that parses a large CSV file into a list of records. The original lazy version looks like this:
-- CsvParser.hs
module CsvParser where
data Row = Row { fields :: [String] }
parseRows :: String -> [Row]
parseRows input = map toRow (lines input)
where
toRow = Row . splitOnComma
When parseRows is called, fields remains a thunk that holds a reference to the entire input string. If the caller only consumes a few rows, the rest of the input stays alive, causing a space leak.
Enabling StrictData in this module forces the constructor to evaluate fields immediately:
{-# LANGUAGE StrictData #-}
module CsvParser where
data Row = Row { fields :: [String] }
parseRows :: String -> [Row]
parseRows input = map toRow (lines input)
where
toRow = Row . splitOnComma
Now each Row contains a fully evaluated list of fields at construction time, breaking the reference to the original input string. The memory usage drops dramatically, as confirmed by a space‑profile run:
# ghc -O2 CsvParser.hs -prof -fprof-auto
# ./CsvParser +RTS -hT
# grep 'Rows' heap.prof
# The strict version shows a flat heap profile; the lazy version grows linearly.
Unboxing: The Hidden Performance Boost
Strict fields are a prerequisite for GHC’s -funbox-strict-fields optimization (enabled automatically at -O2). Primitive types such as Int or Double can be stored directly inside the constructor rather than as a pointer to a heap object, reducing indirection and improving cache locality.
Example:
data Point = Point !Int !Int
Compile with -O2 -ddump-simpl and inspect the STG output: you’ll see Int# tokens inside the constructor, indicating unboxed representation. This can shave a few nanoseconds per field in tight loops.
Migration Patterns and Caveats
When refactoring an existing codebase, a common pattern is:
- Identify performance‑critical modules (parsers, numeric pipelines).
- Add
{-# LANGUAGE StrictData #-}at the top of each module. - Run
+RTS -hT -pto confirm that the space leak is gone. - Check that recursive structures still work. If a data type represents a stream, keep it lazy or add a
BangPatternonly to the fields that need to be strict. - Verify that external libraries used by the module don’t rely on lazy fields. Some generic deriving or lens libraries may assume laziness; in that case, keep a per‑field bang pattern instead of module‑wide
StrictData.
Be aware of the following pitfalls:
- Bottoms early: If a field is strict and the value is bottom, the constructor will throw an exception immediately, which may be undesirable for lazy parsing stages.
- Stack overflow: Deeply nested strict constructors can cause large stack frames during construction. Tail‑recursion or lazy evaluation may be safer for very deep data.
- TemplateHaskell: Splices that generate data types ignore the surrounding module’s
StrictDatasetting; you must explicitly add bangs in the generated code if strictness is required. - Sharing loss: If the same expensive computation is inserted into multiple constructors, it will be recomputed each time unless you share it beforehand.
Actionable Checklist
- Run a space profiler on the current build:
./app +RTS -hT. - Locate modules with large data structures that are not fully traversed.
- Add
{-# LANGUAGE StrictData #-}to those modules. - Re‑run the profiler; confirm that retained memory stabilizes.
- If you need unboxing, compile with
-O2and verify that primitive fields are unboxed via-ddump-simpl. - Test edge cases: recursive types, lenses, and generic deriving to ensure no runtime errors appear.
- Document the change in your commit message so future developers understand the strictness decision.
StrictData is a lightweight, module‑level tool that can eliminate many common space leaks and unlock unboxing optimizations. Use it judiciously: enable it where strictness guarantees safety and performance, and keep laziness for recursive or infinite data structures.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.