← Back to DevBytes

Memory Management in Erlang: A Deep Dive

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:

Memory Layout: Heap, Stack, and More

Each Erlang process has the following memory areas:

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:

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:

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:

%% 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:

%% 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:

%% 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:

%% 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:

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.

🚀 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