The BEAM, from the ground up
What the BEAM virtual machine is, why immutability and processes matter, and the functional ideas every BEAM language shares.
What the BEAM Actually Is
A virtual machine built for systems that never stop - and the five very different languages that run on it.
Most programming languages you have met assume a single program, running on a single thread, that you restart when something goes wrong. The BEAM assumes the opposite. It is a virtual machine - the runtime that actually executes your code - designed at Ericsson in the late 1980s and early 1990s to run telephone exchanges: software that must handle millions of simultaneous calls, recover from faults on its own, and never be taken offline. Those requirements, not academic taste, are why the BEAM looks the way it does, and why every language that targets it shares a surprising amount of DNA.
The name BEAM stands for Bogdan/Bjorn's Erlang Abstract Machine. It is the runtime inside Erlang/OTP, the distribution you install to get Erlang. Your source code is compiled to BEAM bytecode (files with a .beam extension), and the BEAM executes that bytecode, schedules work across CPU cores, manages memory, and provides the concurrency and fault-tolerance machinery the rest of these lessons explore. Think of it the way the JVM relates to Java, or the CLR to C# - a shared runtime that several languages can compile down to.
One VM, five very different languages
The whole point of this track is that the runtime is the common ground, even when the languages look nothing alike. Here is "hello, world" in all five BEAM languages this site covers - the same VM underneath each:
%% Erlang - the original (1986), dynamically typed, Prolog-flavoured syntax
-module(hello).
-export([world/0]).
world() ->
io:format("hello from Erlang~n").
# Elixir - dynamic, Ruby-ish surface, powerful macros
defmodule Hello do
def world do
IO.puts("hello from Elixir")
end
end
// Gleam - STATICALLY typed with sound inference; compiles to Erlang AND JavaScript
import gleam/io
pub fn main() {
io.println("hello from Gleam")
}
;; LFE (Lisp Flavoured Erlang) - a Lisp on the BEAM; code is data
(defmodule hello
(export (world 0)))
(defun world ()
(io:format "hello from LFE~n"))
-- Luerl runs Lua 5.x ON the BEAM: a Lua interpreter written in Erlang.
-- This is ordinary Lua source; Luerl (an Erlang library) executes it.
print("hello from Lua, running on Luerl")
Notice how Luerl is the odd one out: Erlang, Elixir, Gleam, and LFE all compile to BEAM bytecode, while Luerl is a Lua interpreter written in Erlang - it parses Lua source and runs it on the BEAM as a sandboxed, embeddable library. The language you write is plain Lua 5.x; the BEAM is its host. The other four are genuinely compiled to .beam files and can call each other's modules directly.
Why a shared VM matters
Because they share a runtime, these languages share capabilities, not just a logo. Code written in Gleam can call an Erlang library and vice versa with no foreign-function-interface ceremony, because after compilation it is all BEAM bytecode operating on the same data representations. A supervision tree (lesson 5) written in Erlang can supervise an Elixir process. The concurrency model, the garbage collector, the distribution protocol, and the data types are all provided once, by the BEAM, and inherited by everyone above it.
The rest of this track is about those inherited ideas: immutability (lesson 2), the functional style of transforming data (lesson 3), pattern matching (lesson 4), lightweight processes and message passing (lesson 5), and the "let it crash" / supervision philosophy (lesson 6). Learn them once here, and every BEAM language will feel like a dialect rather than a new world.
Further reading: The BEAM Book - an in-depth guide to the Erlang runtime.
Immutability: Why Nothing Ever Changes
On the BEAM data never mutates in place - and that single rule is what makes safe concurrency and crash recovery possible.
The most important rule on the BEAM is also the one that surprises newcomers the most: data is immutable. Once a value exists, it never changes. There is no in-place edit of a list, a map, or a record. When you "update" something, you actually build a new value that shares as much structure as possible with the old one, and the old one keeps existing until nothing refers to it. This is not a style preference bolted on by one language - it is baked into the BEAM itself, so every language on it inherits it.
Erlang takes this to its logical end with single-assignment variables: a variable is bound to a value exactly once within its scope and can never be rebound. To a programmer from Python or JavaScript this looks like a bug at first:
X = 10.
X = 20. %% ** exception error: no match of right hand side value 20
That second line is not an assignment failing - it is a pattern match (lesson 4) of 20 against the already-bound X = 10, which does not match. To get a changed value you bind a new name:
X = 10,
Y = X + 1, %% Y is 11; X is still 10
List = [1, 2, 3],
List2 = [0 | List]. %% [0,1,2,3] - a NEW list; List is untouched
Updating means rebuilding
Every BEAM language expresses "change" as "produce a new value." Here is the same idea - updating a map - in three of them. In each case the original map is unchanged and a new one is returned:
person = %{name: "Ada", age: 36}
older = %{person | age: 37} # NEW map; `person` still says 36
# person.age => 36, older.age => 37
Person = #{name => "Ada", age => 36},
Older = Person#{age => 37}. %% NEW map; Person still has age 36
// Gleam records are immutable too; you build a new one
pub type Person {
Person(name: String, age: Int)
}
pub fn have_birthday(p: Person) -> Person {
Person(..p, age: p.age + 1) // a NEW Person, p unchanged
}
Why immutability is the foundation, not a luxury
Immutability sounds wasteful - aren't we copying everything? Two facts make it practical. First, the BEAM uses persistent data structures: a new list or map shares unchanged parts with the old one, so you copy only what differs, not the whole thing. Second, and more importantly, immutability is what makes the BEAM's headline features even possible:
- Safe concurrency (lesson 5). Two processes can hold the same value at the same time and neither can corrupt it, because neither can change it. There are no data races to defend against, because there is no shared mutable state. This is the single biggest reason concurrent BEAM code is so much easier to get right than concurrent code with locks and shared memory.
- Crash recovery (lesson 6). When a process dies and is restarted, there is no half-mutated global state left behind to clean up. Each process owns its own immutable data; killing it leaves the rest of the system pristine.
- Reasoning by substitution. If a name always means the same value, you can read a function top to bottom and trust that a value does not silently change under you - the property functional programmers call referential transparency.
Even Luerl, which runs Lua - a language whose tables are normally mutable - implements that mutation functionally underneath: each Luerl call returns a new VM state rather than mutating one in place, so the Erlang host stays immutable while the Lua you wrote still looks mutable. Immutability is so fundamental to the BEAM that even a guest language has to make peace with it.
Further reading: Erlang docs - Data Types and variables.
Thinking Functionally: Recursion, Functions as Values, and Pipelines
With no mutable loop counters, BEAM code transforms data with recursion and higher-order functions instead.
If variables can't change (lesson 2), how do you write a loop? You don't - at least not the way C or Python does. There is no mutable counter to increment. Instead, BEAM languages are functional: you express computation as transforming data into new data, using two tools that take a little getting used to and then become second nature - recursion and higher-order functions (functions that take or return other functions).
Recursion replaces the loop
To sum a list, a recursive function handles one element and calls itself on the rest. The classic Erlang shape uses pattern matching (lesson 4) to split a list into its head and tail:
sum([]) -> 0; %% empty list: the answer is 0
sum([H | T]) -> H + sum(T). %% head H, plus the sum of the tail T
%% sum([1,2,3]) = 1 + (2 + (3 + 0)) = 6
A practical concern: naive recursion grows the call stack. BEAM languages rely heavily on tail recursion - when the recursive call is the last thing a function does, the VM reuses the current stack frame instead of adding a new one, turning the recursion into a constant-space loop. The idiom is an accumulator argument that carries the running result:
sum(List) -> sum(List, 0). %% public entry point
sum([], Acc) -> Acc; %% nothing left: return what we accumulated
sum([H | T], Acc) -> sum(T, Acc + H). %% TAIL call - constant stack space
Because this pattern is so common, you rarely write it by hand in day-to-day code - you reach for higher-order functions over collections instead.
Functions are values
On the BEAM a function is an ordinary value: you can store it in a variable, pass it as an argument, and return it. Functions that do so are called higher-order. The three workhorses are map (transform every element), filter (keep some), and fold/reduce (collapse to a single value). Here they are in Erlang, where fun(X) -> ... end is an anonymous function:
Nums = [1, 2, 3, 4, 5],
Doubled = lists:map(fun(X) -> X * 2 end, Nums), %% [2,4,6,8,10]
Evens = lists:filter(fun(X) -> X rem 2 =:= 0 end, Nums), %% [2,4]
Total = lists:foldl(fun(X, Acc) -> X + Acc end, 0, Nums). %% 15
Pipelines: reading transformations left to right
Nesting map inside filter inside fold gets hard to read inside-out. Several BEAM languages add a pipe operator that threads a value through a series of functions, so you read the data flow top-to-bottom. Elixir's |> passes the value on its left as the first argument of the function on its right:
result =
1..5
|> Enum.filter(fn x -> rem(x, 2) == 1 end) # keep odds: [1, 3, 5]
|> Enum.map(fn x -> x * x end) # square: [1, 9, 25]
|> Enum.sum() # add up: 35
Gleam has the same idea with |>, and because Gleam is statically typed the compiler checks that each step's output type matches the next step's expected input:
import gleam/list
import gleam/int
pub fn main() {
[1, 2, 3, 4, 5]
|> list.filter(fn(x) { x % 2 == 1 }) // [1, 3, 5]
|> list.map(fn(x) { x * x }) // [1, 9, 25]
|> int.sum // 35
}
Even Lua, via Luerl, leans functional when you want it to - functions are first-class values you can pass around, and you can fold a table by hand:
local nums = {1, 2, 3, 4, 5}
local total = 0
for _, x in ipairs(nums) do total = total + x end -- 15
local function apply(f, x) return f(x) end -- functions are values
print(apply(function(n) return n * n end, 6)) -- 36
The mental shift is this: instead of commanding the machine step by step while mutating state, you describe a series of transformations from old immutable data to new immutable data. Recursion, higher-order functions, and pipelines are the three tools that make that style comfortable - and they are the same three tools across every BEAM language.
Further reading: Elixir Getting Started - Enumerables and the pipe operator.
Pattern Matching: The = That Isn't Assignment
Matching values against shapes - to bind variables, choose branches, and destructure data - is the BEAM's central control structure.
In most languages = means "assign." On the BEAM it means match. Pattern matching is the single most pervasive idea in BEAM languages: it is how you bind variables, how you pull apart data structures, how you choose which branch of code runs, and how you read messages between processes (lesson 5). Once it clicks, you start seeing it everywhere.
A match takes a pattern on the left and a value on the right. If the value has the shape of the pattern, any unbound variables in the pattern get bound to the corresponding pieces; if it doesn't, the match fails (raising an error, or moving to the next clause). Here it is in Erlang, destructuring - pulling a compound value apart - in a single line:
{ok, Value} = {ok, 42}, %% binds Value = 42
[First | Rest] = [1, 2, 3], %% binds First = 1, Rest = [2, 3]
#{name := N} = #{name => "Ada", age => 36}. %% binds N = "Ada"
If the shapes disagree, you get a badmatch error - which is a feature: it stops the program the instant data isn't what you expected, rather than letting a wrong value flow onward.
{ok, Value} = {error, timeout}. %% ** exception error: no match - and that's good
Function clauses: choosing branches by shape
The most powerful use is selecting which function body runs based on the shape of the arguments. A function can have several clauses, tried top to bottom, and the BEAM picks the first whose patterns match. This replaces long if/else chains with a clean, declarative table of cases. Erlang:
greet({english, Name}) -> io:format("Hello, ~s!~n", [Name]);
greet({spanish, Name}) -> io:format("Hola, ~s!~n", [Name]);
greet({_, Name}) -> io:format("Hi, ~s!~n", [Name]). %% _ matches anything
Elixir spells the same idea with case and with multi-clause def, and adds guards (the when clause) for conditions a pattern alone can't express:
def classify(n) when n < 0, do: :negative
def classify(0), do: :zero
def classify(n) when n > 0, do: :positive
case File.read("config.txt") do
{:ok, contents} -> IO.puts(contents)
{:error, reason} -> IO.puts("could not read: #{reason}")
end
That {:ok, _} / {:error, _} shape is the BEAM's universal convention for results, and pattern matching is how you handle both outcomes without ever forgetting one.
Statically-checked matching in Gleam
Gleam takes pattern matching one step further: because it is statically typed, its case expressions are exhaustively checked at compile time. If you forget to handle a possible variant, the program won't compile - the type system guarantees you covered every case:
pub type Light {
Red
Yellow
Green
}
pub fn next(light: Light) -> Light {
case light {
Red -> Green
Green -> Yellow
Yellow -> Red
// omit a case and Gleam refuses to compile: "this case expression
// does not have a pattern for all possible values"
}
}
Matching in a Lisp and in Lua
LFE, being a Lisp, writes patterns as S-expressions but the mechanism is identical - case matches a value against a series of patterns:
(defun describe (x)
(case x
((tuple 'ok value) (io:format "got ~p~n" (list value)))
((tuple 'error reason) (io:format "failed: ~p~n" (list reason)))
(_ (io:format "unknown~n" ()))))
Lua (and therefore Luerl) has no built-in pattern matching of this kind - it uses ordinary if/elseif and multiple assignment to destructure - a useful reminder that this feature is a hallmark of the compiled BEAM languages, inherited from Erlang, rather than something the VM forces on a guest:
local function describe(tag, value)
if tag == "ok" then return "got " .. tostring(value)
elseif tag == "error" then return "failed: " .. tostring(value)
else return "unknown" end
end
Pattern matching ties the whole platform together: the same =-is-match, the same head/tail list split, the same {:ok, _} / {:error, _} convention show up in process mailboxes next, where matching is how a process decides which message to handle.
Further reading: Elixir docs - Pattern matching.
Processes and Messages: The Actor Model
The BEAM runs millions of cheap, isolated processes that share nothing and talk only by sending messages.
This is the idea the BEAM exists for. A process on the BEAM is not an operating-system process or thread - it is a vastly lighter unit managed by the VM itself. A process starts in well under a microsecond, uses only a couple of kilobytes of memory, and a single BEAM node routinely runs hundreds of thousands or millions of them at once. The VM has a preemptive scheduler (one per CPU core) that gives every process a fair slice of time, so no single process can hog a core. This is what "concurrency-oriented programming" means: you model your problem as many small concurrent actors.
Three rules define a BEAM process, and together they form what is called the actor model:
- Total isolation. Each process has its own private, immutable heap. No process can read or write another's memory. There is no shared mutable state - which (lesson 2) is exactly why concurrency here is so safe.
- Communication only by message passing. Processes interact by sending each other asynchronous messages. A message is a copy of an immutable value dropped into the recipient's mailbox.
- Selective receive. A process reads its mailbox by pattern matching (lesson 4) on messages, handling the ones it understands and leaving the rest.
Spawning and messaging in Erlang
spawn starts a process and returns its PID (process identifier). ! sends a message. receive blocks until a matching message arrives:
-module(counter).
-export([start/0, loop/1]).
start() -> spawn(?MODULE, loop, [0]). %% returns a PID
loop(Count) ->
receive
{inc, From} ->
New = Count + 1,
From ! {count, New}, %% reply to the caller
loop(New); %% TAIL-recurse with the new state
stop ->
ok %% returning ends the process
end.
Look closely at how state works without mutation: the process "remembers" its count by passing it as the argument to the next loop/1 call. State on the BEAM is a recursive loop carrying immutable data forward - lesson 2 and lesson 3 combined. Using it from the shell:
Pid = counter:start(),
Pid ! {inc, self()}, %% send; self() is our own PID for the reply
receive {count, N} -> N end. %% receive the reply: 1
The same model in Elixir
Elixir exposes the identical primitives with friendlier names - spawn, send, and receive:
pid = spawn(fn ->
receive do
{:hello, from} -> send(from, {:reply, "hi!"})
end
end)
send(pid, {:hello, self()})
receive do
{:reply, msg} -> IO.puts(msg) # => hi!
end
In practice you rarely write raw receive loops in Elixir or Erlang - you use OTP behaviours like gen_server that package this spawn/state/receive pattern into a tested abstraction. But under the hood every one of them is exactly the loop above. Gleam provides the same actor model through its gleam_otp library with type-safe messages, and LFE writes spawn/!/receive as S-expressions - all four compiled languages share one runtime concurrency model.
Why this design wins
Because processes share nothing, there are no locks, no mutexes, and no data races to reason about - the entire category of bugs that makes shared-memory threading so painful simply does not exist. Because they are cheap, you can spawn one per connection, per user, per job, rather than rationing a thread pool. And because they are isolated, one crashing process cannot corrupt another - which is the door into the final lesson, where crashing becomes a strategy rather than a failure.
Further reading: Erlang docs - Processes and concurrent programming.
Let It Crash: Fault Tolerance and Supervision
Isolated processes plus supervisors that restart them turn crashing into a reliability strategy.
Most languages teach you to fear crashes: wrap everything in try/catch, check every error, defend every line, because one unhandled exception takes down the whole program. The BEAM inverts this completely. Its philosophy is "let it crash": when a process hits a situation it can't handle, the best thing it can do is die cleanly and immediately - and let a supervisor restart it from a known-good state. This is not recklessness; it is the most reliable strategy ever proven in production telecom systems with famous "nine nines" (99.9999999%) uptime figures.
Three properties from earlier lessons make this safe:
- Isolation (lesson 5): a crashing process cannot harm any other, so killing it is contained.
- Immutability (lesson 2): there is no half-mutated shared state left behind to corrupt the rest of the system.
- Cheap processes (lesson 5): restarting one is nearly free, measured in microseconds.
Links and monitors: noticing a death
Processes can be linked so that the death of one is signalled to the other, or monitored so that one is notified of another's death without dying itself. Supervisors are built on these primitives. In Erlang, spawning with a link and trapping exits lets a parent observe a child's failure:
process_flag(trap_exit, true), %% convert exit signals to messages
Pid = spawn_link(fun() -> 1 / 0 end), %% child crashes (badarith)
receive
{'EXIT', From, Reason} ->
io:format("child ~p died: ~p~n", [From, Reason])
%% => child <0.x.0> died: {badarith, ...}
end.
Supervisors: restart, don't patch
You almost never wire up links by hand. OTP provides the supervisor behaviour: a process whose only job is to start child processes and restart them according to a declared strategy when they die. A supervisor doesn't try to understand why a child failed - it just restores the system to a working state. Here is a minimal Elixir supervisor starting one worker:
children = [
# if this worker crashes, the supervisor restarts it automatically
{MyApp.Worker, []}
]
# :one_for_one => restart only the child that died
Supervisor.start_link(children, strategy: :one_for_one)
The common restart strategies are one_for_one (restart just the dead child), one_for_all (restart all children if one dies, when they depend on each other), and rest_for_one (restart the dead child and those started after it). Supervisors can supervise other supervisors, producing a supervision tree: workers at the leaves do the real work and are allowed to crash, while the supervising structure above them keeps the whole system alive.
The split: defensive code vs. let it crash
The practical rule is to separate the happy path from failure handling. Worker code is written for the case where everything goes right - no defensive checks, no swallowing of errors. If an assumption is violated, the process crashes, and a supervisor's restart logic - written once, in one place - handles recovery. Contrast the two mindsets:
# Defensive style (NOT idiomatic on the BEAM): error checks everywhere
def handle(input) do
case validate(input) do
{:ok, v} -> case process(v) do
{:ok, r} -> {:ok, r}
err -> err
end
err -> err
end
end
# Let-it-crash style: assume success; a bad case simply crashes this
# process, and its supervisor restarts it from a clean state.
def handle(input) do
{:ok, v} = validate(input) # crashes here if invalid - on purpose
{:ok, r} = process(v) # the match itself is the assertion
r
end
This is why pattern matching (lesson 4) doubles as error handling: {:ok, v} = validate(input) is simultaneously "extract the value" and "assert success, or crash trying." The result is code that is short, optimistic, and more reliable than its defensive cousin - because recovery is centralized in supervisors instead of scattered as ad-hoc checks no one maintains.
Together, the six ideas in this track form a single coherent design: a VM (lesson 1) running immutable data (lesson 2) transformed functionally (lesson 3) and dispatched by pattern matching (lesson 4), inside millions of isolated message-passing processes (lesson 5) organized into self-healing supervision trees (lesson 6). Master these and you understand not one BEAM language, but all of them.
Further reading: Erlang docs - Supervisor behaviour and OTP design principles.