← Back to DevBytes

Memory Management in Haskell: A Deep Dive

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:

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:

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:

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:

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:

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 -> "") lines <>
              B.singleton "
" <> B.fromText s <> "
" 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

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:

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:

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.

🚀 Need a reliable AI agent for your project?

Deploy Hermes Agent in 10 minutes. Managed hosting, zero DevOps.

Get Started — $23.99/mo
← Back to all articles