Before you begin
Why this matters
A Haskell program can use far more memory than its results need when unfinished work keeps a large value reachable. Knowing what the collector looks for lets you find that work and change when it happens.
What this helps you reason about
- Predict which values are still reachable after a line of main runs.
- Recognize a binding whose unfinished work keeps a whole list in memory.
- Close handles with hClose or withFile instead of waiting for the garbage collector.
In Haskell you never free memory yourself, and the common picture, that values you are finished with are cleaned up for you, is correct. This lesson makes it exact for GHC: which values the runtime may reclaim, how unfinished work can keep a large value in memory, and why you cannot rely on when it happens.
Already know this? The short version
- GHC reclaims a value some time after nothing still to run refers to it; thunks are part of that, so unfinished work keeps the values it will use reachable.
- The collector traces, so cycles built by recursive definitions are reclaimed like any other values.
- A space leak is usually a thunk that holds a large structure: two results that walk one list separately keep the whole list until the second one is computed.
- Walk the data once with a strict fold, or force results early with seq, bang patterns, strict fields, or modifyIORef'.
- Finalizers on Handles may run late or never; use withFile, bracket, or hClose. Measure with +RTS -s, whose maximum residency shows retained memory.
Words in this lesson (8)
- garbage collector
- The part of GHC’s runtime that finds values that are no longer reachable and reuses their memory.
- thunk
- GHC’s stored description of work that has not happened yet. When the value is needed, GHC runs the work and replaces the thunk with the result.
- root
- A starting point that the garbage collector always treats as in use: the part of the program still to run, including the unfinished work it will need.
- reachable
- A value is reachable when the program can still get to it by following references from what is still running, including thunks that refer to it.
- reference counting
- A way to reclaim memory by counting the references to each value and freeing the value when its count reaches zero. Values that refer to each other keep each other’s counts above zero, so counting alone never frees a cycle.
- tracing collector
- A garbage collector that starts from the roots, marks every value it can reach, and reclaims the rest. Values that refer only to each other are reclaimed too.
- space leak
- Memory a Haskell program keeps for longer than its results need, often because a thunk still refers to a large value.
- finalizer
- An action that GHC may run some time after a value becomes unreachable, such as the one that closes an unreachable Handle. There is no promise that it runs before the program ends.
Examples are focused snippets; imports, enclosing functions, or classes may be omitted. C examples use C11; Haskell examples use Haskell 2010. Expand the notes for language-specific depth.
01 / The common picture
What happens to values you stop using?
You never free memory yourself. GHC’s runtime has a garbage collector, which reuses the memory of values you are finished with. But how does it know that you are finished with a value?
The collector cannot know what your program will do next. So it asks a question it can answer: does anything still to run refer to the value? A value becomes eligible for reclamation, which means its memory may be reused, when nothing does.
Values are cleaned up when nothing still to run refers to them, not when you stop using them.
02 / Roots
Where does the collector start looking?
The collector starts from a few places that the program can always use. These starting points are called roots. In Haskell, the root is the part of the program still to run: the rest of main and every value it will need.
Unfinished work counts. A binding that nothing has needed yet is a thunk, and a thunk refers to the values its work will use. So a list stays reachable as long as some thunk still to be evaluated refers to it, even if no code has used the list for a while.
let swipes = [1 .. 5000] :: [Int]
firstSwipe = head swipes
count = length swipes -- a thunk that refers to swipes
print firstSwipe
print count A value stays reachable while anything still to run, including a thunk, refers to it.
Precisely: top-level values
A top-level value without arguments, such as ruler = repeat '-', is computed at most once and shared. A compiled program can reclaim it once no code that may still run refers to it. GHCi keeps such values for the whole session, so the stepper never reclaims them.
Experiment
Find the unfinished work that still refers to a list
A binding such as count is a thunk until something needs it, and the thunk refers to the values its work will use. While print count is still to run, the thunk is reachable, so swipes is reachable too, even though nothing has used it since head.
Why it matters In Haskell, the paths that keep a value reachable include unfinished work. Ask which thunks still to be evaluated refer to the value.
Predict both lines. After print firstSwipe, is the list in swipes still reachable, and through what? Then delete the line print count and predict which stops disappear.
main :: IO ()
main = do
let swipes = [1 .. 5000] :: [Int]
firstSwipe = head swipes
count = length swipes
print firstSwipe
print count
What to look for
GHCi stops where values are needed and shows the bindings in use there, one location per stop. At print firstSwipe, firstSwipe is _ (unevaluated); GHCi then stops in firstSwipe and in swipes as head needs them, and 1 prints. At print count, count is still _ (unevaluated): unfinished work. The next stop is inside count, where swipes shows 1 : _ (unevaluated): that unfinished work refers to the list, so the list is still reachable after 1 prints. After the edit, the run ends once 1 prints, with no stop in count: nothing still to run refers to swipes.
Expected output
1
500003 / Cycles
What happens to values that refer to each other?
There are two common ways to reclaim memory. Reference counting keeps a count of the references to each value and frees the value when its count reaches zero. A tracing collector starts from the roots, marks everything it can reach, and reclaims the rest.
They differ on cycles: values that refer to each other. In Haskell, a cycle comes from recursive definitions, such as a room defined with its booking and a booking defined with its room. With counting alone, each still has one reference, from the other, so neither count reaches zero. GHC’s collector traces, so a cycle that nothing still to run refers to is reclaimed.
bookRoom name teamName = newRoom
where
newRoom = Room name newBooking
newBooking = Booking teamName newRoom -- a cycle GHC traces from what is still to run, so a cycle that nothing reaches is reclaimed.
Coming from Python: CPython counts references and frees most objects as soon as the count reaches zero, with a separate cycle collector for cycles; GHC does not count references.
Precisely: counting in other languages
Reference counting is common where memory must be released at a predictable moment. Swift counts references automatically, and Rust’s Rc type counts the owners of one value. Neither frees a cycle by itself: Swift code marks one side weak, and Rust code uses Weak for one direction.
Experiment
Go round a cycle of two values
newRoom and newBooking are defined in terms of each other, so GHC builds two values that refer to each other: a cycle. Haskell gives no way to ask whether two values are the same one, so the stepper cannot show that going round leads back to the same room; the definitions say so, because newBooking refers to newRoom itself. When nothing still to run refers to current, both are unreachable together, and GHC’s tracing collector reclaims them.
Why it matters Haskell values cannot be changed, but recursive definitions can still make cycles. The collector treats them like any other values: reachable from what is still to run, or not.
Predict both lines. Then change the last line to putStrLn (roomName (room (booking (room (booking current))))) and predict again: how many times does GHCi stop in room, and what prints?
data Room = Room { roomName :: String, booking :: Booking }
data Booking = Booking { team :: String, room :: Room }
bookRoom :: String -> String -> Room
bookRoom name teamName = newRoom
where
newRoom = Room name newBooking
newBooking = Booking teamName newRoom
main :: IO ()
main = do
let current = bookRoom "Birch" "sales"
putStrLn (team (booking current))
putStrLn (roomName (room (booking current)))
What to look for
After sales prints, current shows Room _ (unevaluated) (Booking "sales" (Room ...)). The name is still unevaluated because nothing has needed it yet. GHCi writes ... where it stops printing nested values. That is a depth limit, not a sign that GHCi found the cycle: a value that went on for ever would print the same way. On the last line, GHCi stops in booking, then room, then roomName, and Birch prints when the run ends. After the edit, GHCi stops in booking and room twice each, then in roomName, and Birch prints again.
Expected output
sales
Birch04 / Unused but reachable
Can a value you no longer use stay in memory?
Reachability has one consequence that surprises people. A value the program will never use again stays in memory as long as something still to run refers to it. In Haskell this is called a space leak.
The common case is a thunk that holds a large structure. Below, total walks the list and builds all of it. count = length pages has not run yet, and it refers to the list, so every cell stays reachable until count is computed. With a million numbers, the program holds the whole list at once instead of a few cells at a time.
The fix is to change when work happens: walk the data once and compute both results in that pass, or compute a result before the data it refers to grows. Long-lived structures leak too, as in other languages: a Map kept in an IORef that only grows keeps every entry reachable.
let pages = [1 .. 1000000] :: [Int]
total = foldl' (+) 0 pages -- builds every cell
count = length pages -- still refers to all of them
print total
print count Unfinished work that refers to a large value keeps all of it in memory. Change when the work happens.
Coming from Python: A Python list that two loops walk stays in memory between the loops, as here. A Haskell list is built as it is walked, like a generator, but a second walk still to come keeps every cell of it, like a list.
Precisely: forcing work early
foldl' from Data.List is the strict left fold: it computes the running result at each step. seq a b evaluates a before returning b, a bang pattern such as !n does the same for an argument, and a strict field, written with ! in a data declaration, is evaluated when the value is built. modifyIORef' applies its function at once, where modifyIORef stores a thunk that grows with every call.
Experiment
Keep a whole list reachable through unfinished work
foldl' computes total in one pass and leaves no unfinished additions behind. But count = length pages is unfinished work that refers to the list, so every cell that total walked through stays reachable until count is computed. With six numbers that costs nothing; with a million, the whole list stays in memory.
Why it matters A space leak in Haskell often comes from unfinished work that refers to a large value. Compute results that walk the same data in one pass, or compute them before the data grows.
Predict both lines. When total is printed, the whole list has been built: what still refers to it? Then swap the two print lines and predict which binding holds the list instead.
import Data.List (foldl')
main :: IO ()
main = do
let pages = [1 .. 6] :: [Int]
total = foldl' (+) 0 pages
count = length pages
print total
print count
What to look for
At print count, 21 has printed and count is still _ (unevaluated). The next stop is inside count, where pages shows [1,2,3,4,5,....]: the whole list, built while total was computed. GHCi shortens long values with ....; the list has six numbers. So the list is still reachable through the unfinished count. After the swap, 6 prints first, and the stop inside total shows the whole list instead.
Expected output
21
605 / Timing
When is an unreachable value reclaimed?
Reachability decides which values may be reclaimed. The collector decides when. It may reclaim an unreachable value soon, much later, or never in a short program, and the program gets no signal when it happens.
So never rely on the garbage collector to release anything other than memory, such as an open file, a network connection, or a lock. Release those in your own code: withFile and bracket close a handle when their action finishes, even after an exception.
import System.IO
main :: IO ()
main = withFile "visitors.txt" WriteMode $ \handle ->
hPutStrLn handle "Ana" -- closed when this finishes You choose when to close handles; the collector chooses when to reclaim memory.
Precisely: finalizers, weak pointers, and performGC
GHC attaches a finalizer to each Handle that closes it once the collector finds it unreachable, but finalizers may run late or not at all before the program ends. System.Mem.Weak provides weak pointers, which do not keep a value reachable, and lets you attach your own finalizers with the same lack of promises. System.Mem.performGC asks for a collection and is meant for tests and measurements.
06 / The implementation
What may the runtime change?
The language promises non-strict evaluation and says nothing about memory. How and when memory is reclaimed is GHC’s choice.
GHC’s default collector divides the heap by age and copies the values that are still reachable into fresh space, leaving everything else behind; most values become unreachable soon after they are made. With optimization (ghc -O), GHC may also evaluate some values earlier, when it can prove the result is needed anyway, which changes how long thunks live. The stepper runs code in GHCi, which does not optimize.
Precisely: measure retained memory
Save the million-element program from section 04 as Example.hs, with import Data.List (foldl') and main = do above it. Compile it with ghc -rtsopts Example.hs and run ./Example +RTS -s. GHC 9.0.2 in the stepper’s container reported 28,282,776 bytes maximum residency: the most memory that was reachable at one time. Without the line print count, nothing refers to the list after total, and it reported 44,376 bytes. Compiled with ghc -O, the first program still reported 39,340,560 bytes. Each figure describes one executable.
$ ./Example +RTS -s
500000500000
1000000
88,052,376 bytes allocated in the heap
97,954,384 bytes copied during GC
28,282,776 bytes maximum residency (7 sample(s)) 07 / Try it
Which values can the program still reach?
Predict the output, then follow which work still refers to visits after each line.
main :: IO ()
main = do
let visits = [1 .. 1000] :: [Int]
firstVisit = head visits
busiest = maximum visits
print firstVisit
print busiest
putStrLn "done" What prints? After line 6, how much of visits has been built, and what still refers to it? After line 7?
Reveal the reasoning
1, then 1000, then done. Line 6 needs only head visits, so only the first cell of the list is built. busiest is still a thunk that refers to visits, so the list stays reachable. Line 7 computes busiest: maximum walks the whole list, building it as it goes. After line 7, nothing still to run refers to visits: putStrLn "done" does not use it, so the whole list is unreachable, and the collector may reclaim it when it chooses.
Practice tickets
- GC-101 · The front desk still holds visitors who left → Free
- GC-102 · The printer keeps notifying screens that were closed → Sign in · Pro
- GC-103 · Each tracking code keeps a whole scanned page in memory → Sign in · Pro
Keep exploring
I/O, buffering, and external state →