Diagnosing Haskell Space Leaks from Lazy Thunks and IO Pipelines
A practical diagnostic guide for Haskell space leaks from lazy evaluation and IO pipelines, using RTS stats, heap profiling, and targeted strictness fixes.
06 Sept 2025, 20:03 UTC

Recognizable condition: memory grows with input size
A space leak in Haskell typically manifests as monotonic resident memory growth while processing larger inputs. While small inputs complete quickly, larger inputs cause Garbage Collection (GC) time to dominate CPU usage, leading to system swapping or an Out-of-Memory (OOM) kill. When running with +RTS -s, you will observe high max residency and frequent GCs, rather than a steady allocation rate. This pattern is common in pure folds, accumulators, and IO pipelines that retain lazy handles.
Useful takeaway: Measure residency first to confirm the leak, then locate the specific retained thunk chain. Strictness changes should be applied based on measured peaks, not as a general blanket approach.
Cause and diagnostic table
| Cause | Typical symptom | Typical site |
|---|---|---|
| Accumulating thunks from lazy left fold | Residency grows linearly with input; GC dominates CPU | foldl on lists, manual accumulator recursion |
| Lazy IO handles held open | File descriptor growth; memory climbs during reads | hGetContents, lazy ByteString read, interleaved IO |
| Lazy fields in records | Single reference keeps whole unevaluated structure | Data types with non-strict fields, large trees |
| Lazy ByteString concatenation | Intermediate thunks build up before consumption | ++ on lazy ByteString within a loop |
Ordered checks
- Reproduce with minimal input: Build the application with
ghc -O2 -Wall -fno-ignore-asserts -o prog Main.hs. Run the binary on small and medium inputs to establish a baseline. This must be run on the build host with read access to the input files. - Capture RTS stats: Run the program using
./prog input +RTS -s -RTS. Examine the "max residency" and "GC time." High max residency paired with high GC overhead strongly suggests a retention leak. - Heap profiling: Rebuild the application and run
./prog input +RTS -h -p -RTS. This generates aprog.hpfile. Inspect the residency graph for growing peaks. Note: Heap profiling adds runtime overhead and can mask timing-sensitive bugs. - Local strictness probe: Add
seqordeepseqto a suspect accumulator and re-measure with+RTS -s. Compare the max residency before and after the change to see if the thunk chain is broken. - Review data definitions: Audit the code for
foldl(lazy left fold), non-strict record fields, and lazy IO boundaries.
Fixes tied to findings
Replace lazy foldl with strict foldl'
Data.List.foldl' forces the accumulator at each step, preventing the buildup of a thunk chain. Use this for any strict left accumulation.
-- before: creates a chain of thunks in memory
sumList xs = foldl (+) 0 xs
-- after: evaluates the sum at each step
import Data.List
sumList xs = foldl' (+) 0 xs
Risk: Increasing strictness can change the evaluation order and expose exceptions earlier than they would have appeared in lazy code.
Strict data fields and bang patterns
Use the bang pattern ! on fields that must be evaluated upon construction. For entire modules, the {-# LANGUAGE StrictData #-} extension can be used, but only after verifying that the semantics remain correct.
-- The ! ensures total is evaluated when Acc is created
data Acc = Acc { !total :: Int, items :: [Int] }
Risk: Strict annotations on very large structures may increase allocation pressure if those fields are not always needed.
Force IO results before storing
When an IO action produces a large pure value that is stored for later use, force it using deepseq before returning it to prevent the IO action from remaining as a thunk.
import Control.DeepSeq
-- Force the result to be fully evaluated
result <- fmap (deepseq result) action
Avoid lazy IO
Prefer streaming libraries (such as conduit or pipes) or explicit handle management over hGetContents. Ensure handles are closed promptly and producers are consumed eagerly.
Verification
- Run the program with
+RTS -son a representative input and compare max residency before and after the strictness change. - Build with
+RTS -hand generate the.hpfile to confirm that the residency peaks have shrunk. - Test functional results with small inputs using
ghc -O2 -Wallto ensure that strictness changes have not altered the program's output.
Escalation criteria
Escalate the issue to a senior engineer or runtime specialist if:
- The heap profile shows growth within a library boundary that cannot be modified.
- Strictness changes alter observable semantics or introduce unexpected exceptions.
- Memory growth persists after all obvious strictness annotations, suggesting a need for RTS/GC tuning.
- Profiling indicates a leak in the GHC runtime or FFI interaction requiring a core dump analysis.
Limitations
Heap profiling and -s stats introduce runtime overhead. Strictness is not a universal cure; in some cases, it can increase peak memory if it forces the allocation of a large structure that would have otherwise remained lazy. Lazy IO refactoring often requires significant changes to resource lifetime semantics.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.