How to Find and Fix a Haskell Space Leak in Production
Your service starts at 80 MB of resident memory, and three days later the container gets killed at 2 GB. Nothing in the logs looks wrong, every request still returns the right answer, and the code has no obvious global cache.

Your service starts at 80 MB of resident memory, and three days later the container gets killed at 2 GB. Nothing in the logs looks wrong, every request still returns the right answer, and the code has no obvious global cache. That pattern is the classic Haskell space leak, and it is one of the few production problems that the type checker will never warn you about.
A space leak is not a leak in the C sense. The memory is still reachable, so the garbage collector is doing its job. The problem is that your program is holding on to far more than it needs, usually as unevaluated thunks that pile up until something finally forces them. This guide walks through the order I use to find one, from cheap checks to heap profiles, and the fixes that actually stick.
What a Haskell Space Leak Looks Like in Practice
Most leaks fall into one of three shapes. Knowing which one you have saves hours of guessing.
- Thunk build-up. A lazy accumulator such as a counter in an
IORefor afoldlgrows a chain of(+1)applications instead of a number. Memory climbs steadily with traffic. - Retained structure. A small value keeps a large one alive. Taking the
lengthof a list you also need later forces the whole list into memory at once. - Lazy fields in long-lived state. A record stored in a
TVarorMVargets updated withmodify, and each field holds a reference to the previous version.
The common thread is laziness meeting long-lived data. If you want a refresher on why evaluation is deferred in the first place, our piece on why lazy evaluation in Haskell actually matters covers the model in more depth.
Confirm the Leak Before You Touch Any Code
Rising memory is not always a leak. GHC's copying collector can legitimately use twice the live data, and a big burst of requests can grow the heap without any bug. So measure live data, not resident memory.
Run the binary with +RTS -s (link with -rtsopts first) and look at maximum residency. Then run the same workload twice as long. If maximum residency stays flat, you do not have a leak. If it doubles, you do. Our walkthrough on reading GHC RTS -s output before tuning GC explains each line of that report.
In a running service, expose the same numbers through GHC.Stats with +RTS -T and graph gcdetails_live_bytes over a day. A sawtooth that returns to the same floor is healthy. A floor that keeps rising is your leak.
Profile the Heap to Find the Space Leak Source
Once you know the leak is real, you need to know what is on the heap. Since GHC 9.2 you can do a lot without rebuilding everything for profiling.
Start with info table profiling
Build with -finfo-table-map -fdistinct-constructor-tables and run with +RTS -hi -i0.5. This samples the heap every half second and attributes closures to source locations, with almost no runtime cost. Open the resulting .eventlog in eventlog2html and look for the band that grows without bound.
Fall back to classic profiling when needed
If the info table view is too fine-grained, build with cabal build, enable-profiling and run +RTS -hy to break memory down by type, then -hc for cost centre. A growing band labelled THUNK or * points to thunk build-up. A growing ByteString or Map band points to retained structure.
| Flag | Breaks heap down by | Rebuild needed |
|---|---|---|
-hT | Closure type | No |
-hi | Source location | Info table flags only |
-hy | Haskell type | Full profiling |
-hc | Cost centre | Full profiling |
Fix Thunk Build-Up With Strictness in the Right Place
The fix for an accumulating thunk is to force the value at the moment you store it. Sprinkling bang patterns everywhere is not the answer, and it can slow hot paths down.
- Replace
foldlwithfoldl'fromData.ListorData.Foldable. - Replace
modifyIORefwithmodifyIORef', andmodifyTVarwithmodifyTVar'. - Use
Data.Map.StrictandData.IntMap.Strictfor any map that lives longer than a single request. - Put a bang on the accumulator argument of hand-written loops, as in
go !acc (x:xs).
Be aware that modifyIORef' only forces to weak head normal form. If the value is a pair or a record, the outer constructor gets evaluated but the fields may still be thunks. That is why the next fix matters.
Make Long-Lived Records Strict by Default
Application state, metrics counters and connection tables are the places a Haskell memory leak hides longest. Give those types strict fields with a bang on each one, or enable {-# LANGUAGE StrictData #-} for the whole module that defines them.
A small worked example: a Stats record with three lazy Int fields, updated 500 times a second in a TVar, gained roughly 40 MB an hour in one service I reviewed. Adding StrictData to the module and switching to modifyTVar' flattened it to a constant 3 KB. If you share state across threads this way, the patterns in our guide to Haskell STM for shared state pair well with strict records.
For nested data you truly need fully evaluated, Control.DeepSeq and force work, but they walk the whole structure every time. Use them at boundaries, such as just before writing into a cache, not inside loops.
Stop Retaining Data You Already Used
Retained structure leaks are about sharing. If one expression consumes a list and another still holds a reference to its head, the whole list stays in memory.
- Compute
sumandlengthin a single strict fold instead of two passes over the same list. - Stream large inputs with
conduit,streamingor lazyByteStringreads processed chunk by chunk, rather than reading the whole file and passing it around. - Copy small slices out of large buffers with
Data.ByteString.copyorData.Text.copy, so a 40-byte key does not pin a 64 KB chunk. - Watch closures stored in long-lived maps. A handler that captures the full request record keeps every header and body alive.
Guard Against a Regression
A leak you fixed once tends to come back during a refactor. Two cheap guards help. First, run your integration tests with +RTS -M256m so a leak turns into a test failure instead of a slow production incident. Second, keep the live bytes graph on your dashboard and alert when the post-GC floor rises more than 20 percent over 24 hours.
The nothunks library is also worth a look for state types. It lets you assert in tests that a value contains no unexpected thunks, which catches lazy fields before they ship.
FAQ
Is a Haskell space leak the same as a memory leak?
No, a space leak means memory is still reachable but held for longer or in larger form than necessary. The garbage collector cannot free it because your program still references it, often through unevaluated thunks.
Should I just turn on the Strict extension everywhere?
Usually not, because Strict changes the meaning of every binding in a module and can break code that relies on laziness. StrictData on modules that define long-lived types gives most of the benefit with far less risk.
Do I need a profiling build to find a space leak?
Not always, since -hT works on any binary and -hi only needs the info table flags. A full profiling build is useful when you need cost centre detail, but try the cheaper options first.
The Short Version
Confirm the leak with maximum residency, not resident memory. Profile with -hi or -hT to see what is growing. Then force values where they are stored, make long-lived records strict, and stop holding references to data you already consumed. Do those three things in that order and most leaks disappear in an afternoon.
More in Systems
Systems
Haskell Connection Pooling for Postgres Services
Your Haskell service passes every load test on a laptop, then falls over in production with "too many clients already" from...
Systems
Haskell Graceful Shutdown for Long Running Services
Your Haskell service gets a SIGTERM during a deploy, the process dies mid-request, and somewhere a client sees a 502 while a...
Systems
Haskell FFI for Calling C Libraries Without Crashes
The first time I wrapped a C library with the Haskell FFI, the service ran for eleven days before it segfaulted at three in the...