Understanding Memory Management in Haskell
Memory management in Haskell stands apart from most mainstream programming languages. Instead of manual malloc/free calls or deterministic reference counting, Haskell employs an automatic, generational garbage collector paired with a unique lazy evaluation model. This combination creates memory behavior that can surprise developers coming from strict languages like C, Java, or Python. Understanding how Haskell allocates, retains, and reclaims memory is essential for writing performant, space-efficient functional programs.
What Makes Haskell's Memory Model Unique
Haskell's memory management is shaped by three core design decisions:
- Purely functional semantics — Immutable values simplify garbage collection because objects never point to older generations
- Lazy evaluation — Expressions are not evaluated until their results are demanded, creating thunks (suspended computations) that consume memory until forced
- Graph reduction — The runtime executes programs by reducing a graph of expressions to weak head normal form, sharing sub-expressions via pointers
These features interact in complex ways. Lazy evaluation can reduce memory usage by avoiding unnecessary computations, but it can also cause space leaks where unevaluated thunks accumulate, holding references that prevent garbage collection.
Why Memory Management Matters in Haskell
Even in a high-level language with garbage collection, poor memory patterns degrade performance in measurable ways:
- Space leaks cause memory usage to grow far beyond what the algorithm requires
- Excessive thunk allocation increases GC pressure and cache misses
- Retained data structures prevent the GC from reclaiming dead memory
- Fragmentation from many small allocations slows down subsequent allocations
For long-running server applications, command-line tools processing large datasets, or real-time systems, understanding memory behavior is not optional—it is critical to delivering reliable, predictable performance.
The GHC Runtime System and Heap Architecture
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →How the Garbage Collector Works
The Glasgow Haskell Compiler (GHC) uses a generational, copying garbage collector by default. The heap is divided into two main areas:
- Nursery (generation 0) — A small, frequently collected area where new objects are allocated using a simple bump pointer allocator (extremely fast, typically just a few CPU instructions)
- Old generation (generation 1) — Objects that survive multiple nursery collections are promoted here, collected less frequently
During a minor GC, the runtime scans the nursery, copies live objects to the old generation, and reclaims the nursery space. This is a stop-the-world event but is typically very fast because the nursery is small and most objects are short-lived. Major GCs collect the old generation and are more expensive.
Memory Representation of Values
Every Haskell value is represented as a heap object with a header word containing:
- A tag identifying the constructor (for algebraic data types)
- A pointer to the object's info table (for runtime dispatch)
- Metadata for garbage collection
Consider this simple data type:
data List a = Nil | Cons a (List a)
In memory, Cons 1 (Cons 2 Nil) becomes a chain of heap objects. Each Cons cell occupies two words (header + payload pointers), while Nil is typically represented as a static singleton.
Lazy Evaluation and Thunks
What Are Thunks?
A thunk is a heap-allocated suspended computation. When you write:
let x = expensiveComputation 42
No computation happens immediately. Instead, a thunk is allocated containing:
- A pointer to the function's code
- References to the free variables captured in the closure
- A flag indicating whether evaluation has occurred
When x is first demanded (pattern-matched or printed), the runtime enters the thunk, evaluates the expression, and overwrites the thunk with the result. This self-overwriting behavior, called black-holing, ensures that subsequent uses of x reuse the computed value rather than repeating the work.
Visualizing Thunk Accumulation
Here is a classic space leak example—a tail-recursive function that is not strict enough:
-- DANGEROUS: Lazy accumulator causes a space leak
sumNumbers :: [Int] -> Int
sumNumbers xs = go 0 xs
where
go acc [] = acc
go acc (x:xs) = go (acc + x) xs
main :: IO ()
main = do
let numbers = [1..10000000]
print (sumNumbers numbers)
At first glance this looks safe: go is tail-recursive. However, because (acc + x) is not forced immediately, each recursive call builds a thunk: (0 + 1) + 2) + 3) + .... The accumulator becomes a chain of 10 million unevaluated additions, consuming enormous memory and causing a stack overflow in strict languages—here it manifests as a heap explosion.
Fixing Space Leaks with Strictness
The fix is to force evaluation of the accumulator at each step using BangPatterns or seq:
-- SAFE: Strict accumulator via BangPatterns
{-# LANGUAGE BangPatterns #-}
sumNumbersStrict :: [Int] -> Int
sumNumbersStrict xs = go 0 xs
where
go !acc [] = acc
go !acc (x:xs) = go (acc + x) xs
-- Alternatively, using seq
sumNumbersSeq :: [Int] -> Int
sumNumbersSeq xs = go 0 xs
where
go acc [] = acc
go acc (x:xs) = let !newAcc = acc + x
in newAcc `seq` go newAcc xs
The bang pattern !acc forces the accumulator to weak head normal form before entering the next recursive call. This eliminates the chain of thunks and reduces memory usage from O(n) to O(1).
Common Memory Patterns and Pitfalls
The Foldl vs Foldl' Distinction
This is the single most common source of space leaks in Haskell programs:
-- foldl: Builds up a massive thunk before evaluation
-- foldl (+) 0 [1..n] creates ((((0 + 1) + 2) + 3) + ... + n)
import Data.List (foldl')
-- BAD: Lazy left fold
badSum :: [Int] -> Int
badSum = foldl (+) 0
-- GOOD: Strict left fold
goodSum :: [Int] -> Int
goodSum = foldl' (+) 0
-- The difference becomes dramatic with large lists
main :: IO ()
main = do
let bigList = [1..10000000]
-- This may run in constant memory
print $ foldl' (+) 0 bigList
-- This likely crashes or runs out of memory
-- print $ foldl (+) 0 bigList
Rule of thumb: Always use foldl' (from Data.List) for accumulating results over lists. The apostrophe indicates strictness. Similarly, use foldr when building lazy structures like lists, and foldl' when reducing to a scalar value.
Map and Retainment Issues
Even with strict evaluation, retaining references to intermediate data structures prevents GC:
import Data.Map.Strict (Map)
import qualified Data.Map.Strict as Map
-- BAD: Retains the entire Map while processing each entry
processMapBad :: Map Int String -> IO ()
processMapBad m = do
let keys = Map.keys m
mapM_ (processValue (m Map.!)) keys
where
processValue f k = putStrLn (f k)
-- BETTER: Process incrementally, allowing GC of processed entries
processMapBetter :: Map Int String -> IO ()
processMapBetter m = do
let go currentMap = case Map.minViewWithKey currentMap of
Nothing -> return ()
Just ((k, v), rest) -> do
putStrLn v
go rest
go m
In the bad version, the entire map m remains live throughout the traversal because we hold a reference to it. The better version uses minViewWithKey to progressively shrink the map, allowing the GC to reclaim entries as they are processed.
String and Text Accumulation
The String type ([Char]) is a linked list of characters—memory-heavy and slow. Building up strings via concatenation creates intermediate lists:
-- TERRIBLE: O(n²) time and O(n²) memory for string building
buildReport :: [String] -> String
buildReport lines = "" ++ concatMap wrapTag lines ++ "
"
where
wrapTag s = "" ++ s ++ " "
Use Builder patterns or the text package for efficient string construction:
{-# LANGUAGE OverloadedStrings #-}
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.Lazy.Builder as B
import Data.Text.Lazy.Builder (Builder)
-- GOOD: Builders concatenate efficiently via a tree of chunks
buildReportBuilder :: [Text] -> Text
buildReportBuilder lines =
let build = B.singleton "" <>
foldMap (\s -> "" <> B.fromText s <> " ") lines <>
B.singleton "
"
in B.toLazyText build
Profiling Memory Usage
Using GHC's Runtime Flags
GHC provides powerful flags for observing memory behavior without code changes:
# Compile with profiling enabled
ghc -O2 -rtsopts -prof Main.hs
# Run with memory statistics
./Main +RTS -s -h -RTS
# Common RTS flags:
# -s : Print summary statistics after execution
# -h : Generate heap profile (produces .hp file)
# -hT : Generate a temporal heap profile
# -hy : Profile by type
# -hc : Profile by constructor
# -hr : Profile by retention (who's holding references)
# -xt : Include threads in profile
# -L200 : Set heap size limit in MB
The +RTS -s output includes:
3,200,000,000 bytes allocated in the heap
45,000,000 bytes copied during GC
2,000,000 bytes maximum residency (sample)
500,000 bytes maximum slop
150 MB total memory in use
- Bytes allocated — Total heap allocation; high numbers indicate allocation-heavy code
- Maximum residency — Peak live memory; the key metric for space leaks
- Bytes copied during GC — High numbers suggest short-lived objects churning through the nursery
Heap Profiling by Type
To generate a heap profile broken down by type, use:
./Main +RTS -hy -h -RTS
# Then convert the .hp file to a visual format:
hp2ps -e8 Main.hp
# Or use hp2pretty for better output:
hp2pretty Main.hp
This produces a graph showing which types dominate memory at each point in execution. You might discover that Int thunks, [] constructors, or Data.Text.Internal objects are unexpectedly abundant.
Using the GHC.Prof API
For targeted profiling, use GHC.Prof to measure specific code blocks:
import GHC.Prof
profileBlock :: IO a -> IO (a, Double)
profileBlock action = do
startTime <- getCPUTime
result <- action
endTime <- getCPUTime
let elapsed = fromIntegral (endTime - startTime) / 1e9 -- seconds
return (result, elapsed)
Advanced Techniques for Memory Control
Unpacking and Unboxed Types
By default, data constructors store values as pointers to heap objects. This adds indirection and memory overhead. GHC allows unpacking fields directly into the constructor:
-- WITHOUT unpacking: Each field is a pointer (8 bytes each + heap overhead)
data Point = Point Double Double
-- WITH unpacking: Fields stored inline, no pointer indirection
data PointUnpacked = Point {-# UNPACK #-} !Double {-# UNPACK #-} !Double
-- Even better: Use unboxed primitives when possible
data PointPrim = PointPrim Double# Double#
The {-# UNPACK #-} pragma eliminates the pointer and stores the value directly in the constructor. Combined with ! (strictness), this creates a flat, cache-friendly representation. For numeric code, consider using Double# (unboxed primitive) directly, though this requires working in IO or using ST monads.
Using Compact Regions
For data that is long-lived and read-only, GHC provides compact regions—a contiguous block of memory that can be GC'd as a single unit:
import Data.Compact
-- Create a compact region containing a large, immutable structure
main :: IO ()
main = do
let hugeMap = Map.fromList [(i, show i) | i <- [1..1000000]]
-- Compact the map into a contiguous region
compactedMap <- compact hugeMap
-- Now hugeMap can be GC'd; compactedMap is a single block
-- Operations on compactedMap are fast because of locality
print (Map.size compactedMap)
Compact regions are ideal for:
- Large lookup tables loaded at startup
- Immutable configuration data
- Pre-computed results shared across requests
Mutable Arrays in ST and IO Monads
When you need in-place mutation for performance, use ST or IO with unboxed mutable arrays:
import Control.Monad.ST
import Data.Array.ST
import Data.Array.Unboxed (UArray)
-- Pure function using ST for efficient in-place computation
sieveOfEratosthenes :: Int -> UArray Int Bool
sieveOfEratosthenes limit = runST $ do
-- Allocate mutable array
arr <- newArray (2, limit) True
-- Mutate in place with no GC overhead
let sieve !p
| p * p > limit = return ()
| otherwise = do
val <- readArray arr p
when val $ do
forM_ [p*p, p*p+p .. limit] $ \i ->
writeArray arr i False
sieve (p + 1)
sieve 2
-- Freeze to immutable array when done
unsafeFreeze arr
The ST monad provides:
- Heap-allocated mutable storage that is tracked by the GC
- Guaranteed purity: the result type cannot capture mutable references
- Performance comparable to imperative languages for numerical algorithms
Weak References and Finalizers
For managing external resources, Haskell provides weak pointers and finalizers:
import System.Mem.Weak
-- Create a weak reference that doesn't prevent GC
withFileHandle :: FilePath -> (Handle -> IO a) -> IO a
withFileHandle path action = do
handle <- openFile path ReadMode
-- Add a finalizer to close the file when handle becomes unreachable
_ <- addFinalizer handle (hClose handle)
result <- action handle
return result
Finalizers run when the GC discovers the object is dead, but there is no guarantee when they run. For deterministic resource cleanup, use bracket patterns instead.
Best Practices for Memory-Efficient Haskell
1. Make Accumulator Parameters Strict
Every recursive function with an accumulator should force it at each step:
-- Use BangPatterns, seq, or foldl'
go !acc (x:xs) = go (f acc x) xs -- BangPatterns version
go acc (x:xs) = let !new = f acc x in go new xs -- seq version
2. Prefer Strict Data Types
Use strict fields in data types unless laziness is specifically needed:
data Record = Record
{ field1 :: !Int
, field2 :: !Double
, field3 :: !Text
}
3. Use Unboxed Containers
For numeric data, use vector, bytestring, and text instead of lists:
import Data.Vector.Unboxed (Vector)
-- Vector of Doubles stores raw values, not pointers
let vec = Data.Vector.Unboxed.fromList [1.0, 2.0, 3.0 :: Double]
4. Avoid Retaining Large Structures
Process data incrementally and drop references to consumed portions:
-- Stream processing with conduit, pipes, or streaming libraries
-- These libraries automatically discard processed data
import Streaming
import qualified Streaming.Prelude as S
processLargeFile :: FilePath -> IO ()
processLargeFile path = do
S.readFile path
& S.map processLine
& S.stdout
5. Profile Before Optimizing
Never optimize memory usage without profiling first. Use +RTS -s as a first check, then heap profiles if residency is high. Guesswork leads to unnecessary strictness that can actually harm performance by forcing computations that are never used.
6. Watch for CAF Retention
Constant Applicative Forms (top-level values) are never garbage collected:
-- This CAF will live forever, occupying memory
largeConstant :: Map Int String
largeConstant = Map.fromList [(i, replicate 1000 'x') | i <- [1..1000000]]
-- Better: Compute on demand or use a compact region
7. Leverage the GHC Event Log
For detailed GC analysis, use event logging:
./Main +RTS -l -s -RTS
# This produces Main.eventlog
# Visualize with eventlog2html or ThreadScope
ThreadScope shows GC pauses, thread activity, and heap size over time, making it invaluable for understanding multi-threaded memory behavior.
Conclusion
Memory management in Haskell is a rich and nuanced topic that rewards deep understanding. The lazy evaluation model offers elegance and compositionality but demands vigilance against space leaks. The GHC runtime provides excellent tools—generational GC, compact regions, unboxed types, and comprehensive profiling flags—that allow developers to achieve memory performance rivaling lower-level languages while retaining Haskell's high-level abstractions.
The key takeaway is this: laziness is a double-edged sword. Use it deliberately for composability and infinite data structures, but apply strictness strategically in accumulator paths and data fields. Profile regularly with +RTS -s and heap profiles. Prefer unboxed, strict containers for numeric and bulk data. With these practices, you can write Haskell programs that are both beautiful and memory-efficient, capable of handling gigabytes of data in constant space while maintaining the clarity of pure functional programming.