Understanding Erlang's Memory Architecture
Erlang's memory management model is fundamentally different from most other languages. Built on the BEAM virtual machine, it uses a per-process memory architecture where each Erlang process owns its own private heap. There is no shared global heap for mutable state. This design directly supports the language's core strengths: massive concurrency, fault isolation, and predictable garbage collection patterns.
The Per-Process Memory Model
When you spawn a new process in Erlang, the BEAM allocates a private memory region for it. This region contains the process's heap, stack, and various metadata. Critically, processes do not share memory — they communicate exclusively via message passing, which involves copying data from the sender's heap to the receiver's heap (with an important exception for binaries, which we'll cover later).
This isolation means:
- A crashing process cannot corrupt another process's memory
- Garbage collection happens independently per process — a GC pause in one process never blocks another
- Processes can be killed and their entire memory reclaimed instantly without affecting the rest of the system
Memory Layout: Heap, Stack, and More
Each Erlang process has the following memory areas:
- Process Heap — stores all compound data structures like lists, tuples, records, and maps
- Stack — holds temporary variables, function call frames, and registers during execution
- Binary Heap — a separate shared area for binary data (> 64 bytes) that uses reference counting
- System Memory — areas shared across the VM, such as the atom table, ETS tables, loaded code, and process metadata
Here's a simple demonstration that shows process memory isolation:
%% spawn_two_processes.erl
-module(memory_demo).
-export([start/0, worker/0]).
start() ->
Pid1 = spawn(?MODULE, worker, []),
Pid2 = spawn(?MODULE, worker, []),
Pid1 ! {self(), "hello"},
Pid2 ! {self(), "world"},
receive
{Pid1, Msg1} -> io:format("Pid1: ~p~n", [Msg1]);
{Pid2, Msg2} -> io:format("Pid2: ~p~n", [Msg2])
end,
%% Each process has its own heap — no shared mutable state
io:format("Processes are isolated~n").
worker() ->
receive
{Caller, Msg} ->
%% This data lives only on this process's heap
Caller ! {self(), Msg}
end.
Why Memory Management Matters in Erlang
🚀 Deploy your AI agent in 10 minutes
Managed Hermes hosting. Zero DevOps. 100M tokens/mo included.
Try it free →Understanding Erlang's memory model is critical for several reasons:
- Soft Real-Time Systems — Erlang is often used in telecom switches, messaging systems, and financial platforms where predictable latency is essential. Uncontrolled memory growth or large GC pauses can violate latency guarantees.
- Long-Running Applications — Erlang systems are designed to run for years without restart. Memory leaks, even tiny ones, accumulate over time and eventually cause failures.
- High Concurrency — With millions of processes potentially running, per-process memory overhead must be minimized. Each process's initial heap size and growth strategy directly impacts total system memory.
- Atom Table Exhaustion — Atoms are not garbage collected. Running out of atom space crashes the entire VM, making atom management a crucial concern.
Garbage Collection in Erlang
Erlang employs a fascinating garbage collection strategy: each process has its own generational, copying garbage collector. This is a stark contrast to languages with a single global GC like Java's G1 or Go's concurrent mark-sweep collector.
Generational GC per Process
The Erlang GC is generational, meaning it distinguishes between young data (recently allocated, likely to die quickly) and old data (survived multiple collections, likely persistent). It uses a Cheney-style copying collector: live data is copied from the current heap space (the "from-space") to a new, compacted region (the "to-space"). The old space is then freed in its entirety.
The key insight: because GC is per-process, a collection only pauses the single process being collected. All other processes continue running normally. For a system with thousands of mostly-idle processes, GC overhead is negligible.
Minor vs Major Collections
Each process heap is divided into a young generation and an old generation:
- Minor GC — collects only the young generation. Fast, frequent, and typically very short pause times. Triggered when the young heap fills up.
- Major GC — collects both young and old generations. Slower but still isolated to a single process. Triggered when the old generation needs compaction.
You can observe GC activity using process_info/2:
%% gc_inspection.erl
-module(gc_inspection).
-export([inspect_process/1, create_lots_of_data/0]).
inspect_process(Pid) ->
%% Get memory and GC statistics for a process
Memory = process_info(Pid, memory),
HeapSize = process_info(Pid, heap_size),
GCSize = process_info(Pid, total_heap_size),
Reductions = process_info(Pid, reductions),
io:format("Process ~p:~n", [Pid]),
io:format(" Memory: ~p bytes~n", [Memory]),
io:format(" Heap Size: ~p words~n", [HeapSize]),
io:format(" Total Heap: ~p words~n", [GCSize]),
io:format(" Reductions: ~p~n", [Reductions]).
create_lots_of_data() ->
Self = self(),
%% Spawn a process that builds a large list and then dies
Pid = spawn(fun() ->
BigList = lists:seq(1, 100000),
Self ! {done, length(BigList)}
end),
%% Inspect before GC
timer:sleep(100),
inspect_process(Pid),
receive
{done, Len} ->
io:format("List length: ~p~n", [Len]),
%% After message received, the spawned process has died
%% and its memory was reclaimed
io:format("Process died, memory reclaimed~n")
end.
Binary Memory Management
Binaries are treated specially in Erlang because copying large binary data during message passing would be prohibitively expensive. The BEAM uses a separate binary heap with reference counting to handle binaries efficiently.
The Binary Heap
When you create a binary larger than 64 bytes (the threshold is configurable), it is allocated on a shared binary heap rather than the process's private heap. The process holds a reference (a ProcBin) to that binary data. When sending such a binary in a message, only the reference is copied — not the underlying data. This makes binary message passing extremely cheap.
Reference-Counted Binaries
The binary heap uses reference counting. Each time a process references a binary (via assignment, message passing, or ETS storage), the reference count increments. When a process dies or drops its reference, the count decrements. When the count reaches zero, the binary is freed.
This is critical to understand because it means binaries can outlive the process that created them if other processes still hold references:
%% binary_reference_demo.erl
-module(binary_ref_demo).
-export([demonstrate/0, binary_holder/1]).
demonstrate() ->
%% Create a large binary
LargeBin = <<0:100000/unit:8>>,
io:format("Created binary of size: ~p bytes~n", [byte_size(LargeBin)]),
%% Spawn a process that holds a reference to this binary
HolderPid = spawn(?MODULE, binary_holder, [LargeBin]),
io:format("Holder process: ~p~n", [HolderPid]),
%% Even after the original variable goes out of scope here,
%% the binary survives because HolderPid still references it
io:format("Binary lives on in holder process~n"),
%% Kill the holder — only then is the binary freed
exit(HolderPid, kill),
timer:sleep(100),
io:format("Holder killed, binary ref count reaches zero~n").
binary_holder(Bin) ->
%% This process holds a reference to the binary
%% The ProcBin keeps the binary alive on the binary heap
receive
{check, Caller} ->
Caller ! {binary_size, byte_size(Bin)},
binary_holder(Bin)
after 5000 ->
io:format("Holder exiting, releasing binary~n")
end.
Monitoring and Diagnosing Memory
Erlang provides powerful built-in tools for memory inspection. Knowing how to use them is essential for production systems.
Using erlang:memory()
The erlang:memory() BIF returns memory statistics for the entire VM:
%% memory_stats.erl
-module(memory_stats).
-export([print_all_stats/0]).
print_all_stats() ->
io:format("=== BEAM Memory Statistics ===~n"),
io:format("Total memory: ~p MB~n",
[erlang:memory(total) / 1048576]),
io:format("Processes memory: ~p MB~n",
[erlang:memory(processes) / 1048576]),
io:format("Processes used: ~p MB~n",
[erlang:memory(processes_used) / 1048576]),
io:format("Binary memory: ~p MB~n",
[erlang:memory(binary) / 1048576]),
io:format("ETS memory: ~p MB~n",
[erlang:memory(ets) / 1048576]),
io:format("Atom memory: ~p MB~n",
[erlang:memory(atom) / 1048576]),
io:format("Atom used: ~p MB~n",
[erlang:memory(atom_used) / 1048576]),
io:format("Code memory: ~p MB~n",
[erlang:memory(code) / 1048576]),
io:format("System memory: ~p MB~n",
[erlang:memory(system) / 1048576]),
%% Breakdown by scheduler
io:format("~n=== Per-Scheduler Memory ===~n"),
lists:foreach(
fun(N) ->
case erlang:memory(N) of
Mem when is_integer(Mem) ->
io:format("Scheduler ~p: ~p KB~n", [N, Mem / 1024]);
_ ->
ok
end
end,
lists:seq(1, erlang:system_info(schedulers))
).
process_info and Memory Leaks
To find processes that are consuming excessive memory, you can scan all processes:
%% find_memory_hogs.erl
-module(find_memory_hogs).
-export([scan/0, top_n/1]).
%% Scan all processes and return those using the most memory
scan() ->
Processes = processes(),
Info = lists:map(fun(Pid) ->
case process_info(Pid, [memory, heap_size, total_heap_size,
reductions, message_queue_len,
current_function]) of
undefined -> {Pid, 0, 0, 0, 0, 0, undefined};
InfoList ->
Mem = proplists:get_value(memory, InfoList, 0),
Heap = proplists:get_value(heap_size, InfoList, 0),
TotalHeap = proplists:get_value(total_heap_size, InfoList, 0),
Reds = proplists:get_value(reductions, InfoList, 0),
MQ = proplists:get_value(message_queue_len, InfoList, 0),
Func = proplists:get_value(current_function, InfoList, undefined),
{Pid, Mem, Heap, TotalHeap, Reds, MQ, Func}
end
end, Processes),
%% Sort by memory descending
Sorted = lists:sort(fun({_, M1, _, _, _, _, _}, {_, M2, _, _, _, _, _}) ->
M1 > M2
end, Info),
Sorted.
top_n(N) ->
Top = lists:sublist(scan(), N),
io:format("~n=== Top ~p Memory-Consuming Processes ===~n", [N]),
lists:foreach(
fun({Pid, Mem, Heap, TotalHeap, Reds, MQ, Func}) ->
io:format("~nProcess: ~p~n", [Pid]),
io:format(" Memory: ~p KB~n", [Mem / 1024]),
io:format(" Heap size: ~p words~n", [Heap]),
io:format(" Total heap: ~p words~n", [TotalHeap]),
io:format(" Reductions: ~p~n", [Reds]),
io:format(" Message queue: ~p~n", [MQ]),
io:format(" Function: ~p~n", [Func])
end,
Top
).
Observer and etop
The Observer graphical tool provides a visual representation of process memory usage. You can launch it from the shell:
%% Launch the Observer GUI
observer:start().
For command-line monitoring, etop works like Unix top but for Erlang processes:
%% Start etop (text-based process monitor)
etop:start().
%% Customize output
etop:start([{interval, 2}, {sort, memory}, {lines, 20}]).
%% Stop etop
etop:stop().
Best Practices for Erlang Memory Management
Binary Handling
Binaries are efficient but require careful handling to avoid memory bloat:
- Use binaries for large data — strings as lists of integers are memory-intensive. Use
<<>>binaries for text processing - Match binary patterns carefully — avoid creating unnecessary sub-binaries that keep large parent binaries alive via reference counting
- Free binaries explicitly when possible — in hot loops, use
binary:copy/1to create a new binary that doesn't retain a reference to the original
%% binary_best_practices.erl
-module(binary_best_practices).
-export([bad_sub_binary/1, good_sub_binary/1]).
%% BAD: This creates a sub-binary that keeps the ENTIRE original
%% binary alive on the heap, even if you only need a small slice
bad_sub_binary(BigBinary) ->
%% Extracting 10 bytes from a 10MB binary
Slice = binary:part(BigBinary, {0, 10}),
%% The original 10MB binary is still referenced!
io:format("Slice: ~p~n", [Slice]),
Slice.
%% GOOD: Explicitly copy the slice to release the parent binary
good_sub_binary(BigBinary) ->
Slice = binary:part(BigBinary, {0, 10}),
%% Create an independent copy that doesn't reference the original
IndependentSlice = binary:copy(Slice),
%% Now the original BigBinary can be garbage collected
io:format("Independent slice: ~p~n", [IndependentSlice]),
IndependentSlice.
Atom Management
Atoms are global values that are never garbage collected. The atom table has a finite size (default ~1 million, configurable with +t flag). Exhausting it crashes the VM:
- Never create atoms from untrusted input — avoid
list_to_atom/1on user-supplied strings. Uselist_to_existing_atom/1or better, use binaries and pattern matching - Monitor atom usage — call
erlang:memory(atom_used)periodically in production - Set a reasonable atom limit —
erl +t 500000for a 500k atom limit
%% atom_safety.erl
-module(atom_safety).
-export([safe_atom_lookup/1, unsafe_atom_lookup/1]).
%% SAFE: Only works with existing atoms, crashes on unknown input
safe_atom_lookup(Input) when is_list(Input) ->
try
list_to_existing_atom(Input)
catch
error:badarg ->
{error, "Atom does not exist: " ++ Input}
end.
%% UNSAFE: Creates a new atom from any string — can exhaust atom table
unsafe_atom_lookup(Input) when is_list(Input) ->
list_to_atom(Input).
%% If Input comes from external source (HTTP, socket, file),
%% an attacker can crash your VM by feeding millions of unique strings
ETS Tables and Memory
ETS (Erlang Term Storage) tables live in system memory and are not tied to any process. They require explicit cleanup:
- Always use ownership properly — if you create a table and the creating process dies, the table is deleted automatically. Use a dedicated long-lived process as the owner for persistent tables
- Monitor ETS memory — use
erlang:memory(ets)andets:info(Table, memory) - Clean up unused tables — orphaned tables from debugging or one-off operations can accumulate
%% ets_memory_demo.erl
-module(ets_memory_demo).
-export([create_and_inspect/0, ets_owner_process/0]).
create_and_inspect() ->
%% Create an ETS table
Table = ets:new(my_table, [set, public, {keypos, 1}]),
%% Populate with data
lists:foreach(
fun(I) ->
ets:insert(Table, {I, <<"data_for_row_", I:32/integer>>})
end,
lists:seq(1, 10000)
),
%% Inspect memory usage
Memory = ets:info(Table, memory),
Size = ets:info(Table, size),
io:format("ETS table ~p: ~p rows, ~p words of memory~n",
[Table, Size, Memory]),
%% Check total ETS memory
TotalEtsMemory = erlang:memory(ets),
io:format("Total ETS memory across all tables: ~p KB~n",
[TotalEtsMemory / 1024]),
%% Delete the table explicitly
ets:delete(Table),
io:format("Table deleted, memory reclaimed~n").
%% Pattern: dedicated owner process for long-lived tables
ets_owner_process() ->
spawn(fun() ->
Table = ets:new(persistent_table, [protected, named_table]),
%% This process is now the owner
%% If it dies, the table is automatically deleted
ets_owner_loop(Table)
end).
ets_owner_loop(Table) ->
receive
{insert, Key, Value} ->
ets:insert(Table, {Key, Value}),
ets_owner_loop(Table);
{lookup, Key, Caller} ->
Result = ets:lookup(Table, Key),
Caller ! {result, Result},
ets_owner_loop(Table);
stop ->
ets:delete(Table),
io:format("Table ~p deleted cleanly~n", [Table])
end.
Process Hygiene
Good process management prevents memory leaks:
- Avoid unbounded message queues — a process with a growing mailbox is a memory leak. Use timeouts and selective receive patterns to drain stale messages
- Kill orphaned processes — processes that have finished their work but weren't terminated properly consume memory
- Use process hibernation —
erlang:hibernate/3shrinks a process's heap to just its live data and suspends it, dramatically reducing memory footprint for idle processes
%% process_hygiene.erl
-module(process_hygiene).
-export([bad_message_loop/0, good_message_loop/1, hibernate_example/0]).
%% BAD: This loop accumulates ALL messages, never clearing the queue
%% If messages arrive faster than they're processed, memory grows infinitely
bad_message_loop() ->
receive
Msg ->
%% Process Msg...
io:format("Processing: ~p~n", [Msg]),
bad_message_loop() %% No timeout, no queue management
end.
%% GOOD: Uses timeout to periodically drain unexpected messages
good_message_loop(ExpectedPattern) ->
receive
{data, ExpectedPattern, Payload} ->
io:format("Got expected data: ~p~n", [Payload]),
good_message_loop(ExpectedPattern);
Unknown ->
io:format("Discarding unexpected: ~p~n", [Unknown]),
good_message_loop(ExpectedPattern)
after 5000 ->
io:format("Timeout — cleaning up stale messages~n"),
%% Drain any remaining messages
drain_messages(),
good_message_loop(ExpectedPattern)
end.
drain_messages() ->
receive
_Any ->
drain_messages()
after 0 ->
ok
end.
%% Hibernation example: reduces memory for idle processes
hibernate_example() ->
receive
wake_up ->
io:format("Waking up and doing work~n"),
%% After work is done, hibernate again
erlang:hibernate(?MODULE, hibernate_example, [])
after 10000 ->
io:format("Going to hibernation...~n"),
%% Hibernate shrinks the heap and suspends the process
%% The process will wake up when a message arrives
erlang:hibernate(?MODULE, hibernate_example, [])
end.
Tuning the VM
Several BEAM VM flags allow fine-tuning memory behavior:
# Example VM flags for memory tuning
# -env ERL_MAX_PORTS — maximum number of open ports
# +hmb — binary heap minimum free space before GC (default 32768 bytes)
# +hmaxk — maximum heap size for a process before forced GC (kilowords)
# +hmin — minimum heap size for new processes (kilowords)
# +Mmmbcs — minimum binary virtual heap size
# +MMbcs — binary GC threshold
# Start Erlang with tuned memory parameters:
# erl +hmin 32 +hmaxk 1024 +hmb 65536 +MMbcs 400
Key tuning parameters:
- +hmin N — minimum heap size for new processes in words. Default is small (233 words). For processes you know will need more memory, set higher to reduce early GC cycles
- +hmaxk N — maximum heap size in kilowords before forcing a GC. Prevents runaway processes from consuming all system memory
- +hmb N — binary heap minimum free space. Increasing reduces GC frequency for binary-heavy applications
- +MMbcs N — binary virtual heap GC threshold percentage. Controls when the binary GC triggers
Conclusion
Erlang's memory management model is a cornerstone of its legendary reliability and concurrency capabilities. The per-process heap architecture with isolated garbage collection eliminates "stop-the-world" pauses and provides natural fault containment. The separate binary heap with reference counting enables efficient handling of large data blobs without compromising the copying semantics of message passing.
To master Erlang memory management, internalize these principles: respect the atom table's finite nature, monitor process memory with the built-in introspection tools, keep message queues bounded, use hibernation for idle processes, and tune the VM flags for your workload's characteristics. With these practices, your Erlang systems will deliver the predictable, low-latency performance that has made the language a trusted foundation for critical infrastructure worldwide.