erlang.tech

erlang.tech

Software that keeps running while it is broken

Erlang was built at Ericsson to run telephone exchanges that were never allowed to stop. Every idea here arrives three ways: the Erlang version, the Python version, and the JavaScript version. You already know what a thread, a try block and a global variable do. This guide shows what replaces them when a system has to survive its own failures.

chat_sup room_1 room_2 room_3
Three chat rooms under one supervisor. Watch what happens when one of them crashes.
chapters
27
runnable files
65
dependencies to read it
0
-spec start() -> ok.

Why a language built for telephone exchanges

Erlang answers one question: how do you keep a system running when parts of it are failing. Everything else in the language follows from that answer.

Ericsson built Erlang in the late 1980s for telephone switches. A switch handles hundreds of thousands of calls at once, and it is not allowed to stop, not for a bug, not for a deploy, not for a hardware fault. Those constraints produced a language with an unusual shape, and the shape turns out to suit anything that has to stay up: chat, messaging, payments, queues, real time delivery.

WhatsApp ran two million connections per server on it. Discord passes millions of messages a second through it. RabbitMQ, CouchDB, Riak and the entire Elixir ecosystem sit on the same virtual machine.

why_specs.erl
%% Three functions, three different stories about failure.
-module(why_specs).
-export([greet/1, find_age/2, start/0]).

%% A spec is a promise to Dialyzer, not to the compiler. It is checked
%% statically by a separate tool, and it is worth writing anyway.
-spec greet(binary()) -> binary().
greet(Name) ->
    <<"Hello, ", Name/binary, "!">>.

%% Erlang returns a tagged tuple instead of raising for an expected miss.
%% The caller has to match on it, so the missing case is hard to forget.
-spec find_age(atom(), [{atom(), integer()}]) -> {ok, integer()} | not_found.
find_age(Name, People) ->
    case lists:keyfind(Name, 1, People) of
        {Name, Age} -> {ok, Age};
        false -> not_found
    end.

%% This one talks to the world. Nothing in the type system stops it, but the
%% process it runs in is isolated, so when it fails it takes nothing else
%% down with it.
-spec start() -> ok.
start() ->
    io:format("~s~n", [greet(<<"world">>)]).

The same three functions in the languages you already use. The annotations describe intent and enforce nothing, and the failure in the middle one is invisible from the outside.

Python 3.12
# The same three functions in Python. Type hints document intent and enforce
# nothing: greet() is free to print, open a socket, or raise.
def greet(name: str) -> str:
    return f"Hello, {name}!"


def find_age(name: str, people: dict[str, int]) -> int:
    # Raises KeyError, and nothing in the signature says so. Callers find out
    # in production, and one uncaught raise takes the whole worker down.
    return people[name]


def main() -> None:
    print(greet("world"))


if __name__ == "__main__":
    main()
JavaScript (Node 22)
// The same three functions in JavaScript. JSDoc helps an editor and stops
// nobody: greet can print, fetch and throw without changing shape.
/** @param {string} name */
export function greet(name) {
  return `Hello, ${name}!`;
}

/**
 * @param {string} name
 * @param {Record<string, number>} people
 */
export function findAge(name, people) {
  // undefined for a missing key, with no warning if you forget to check.
  // An unhandled rejection anywhere can end the whole process.
  return people[name];
}

console.log(greet("world"));

Four ideas, and they all connect

isolation

Nothing is shared

Each process has its own heap and its own garbage collector. There is no shared memory to protect, so there are no locks, no mutexes and no data races. Two processes communicate by copying a message, which sounds slow and buys you the ability to reason about each one on its own.

scale

Processes cost almost nothing

An Erlang process is not an operating system thread. It takes about 300 words to create and a few microseconds to start. A million of them on one machine is ordinary, which is why the natural design is one process per connection, per request, per chat room, per anything.

failure

Let it crash

Code handles the cases it understands and dies on the ones it does not. A supervisor notices and starts a clean replacement from a known state. Defensive code disappears, and so does the class of bug where a program limps on in a state its author never imagined.

change

The system stays up while you change it

Modules can be replaced in a running virtual machine. A process picks up the new version on its next fully qualified call, keeping its state. Deploying without dropping connections is a language feature rather than an infrastructure project.

What you give up

What is missingWhat you do instead
Static typesDialyzer, which finds real contradictions without ever crying wolf
Mutable variablesState lives in a process and changes by recursion
Fast number crunchingNIFs in Rust or C, or a different tool for that part
A large standard library for textBinaries, which are excellent, and a smaller string library
Familiar syntaxTwo weeks of commas, semicolons and periods feeling wrong

Every idea here gets an Erlang version with the Python or JavaScript version beside it. Half the chapters build something runnable: a guessing game, a counter, a chat server with supervised rooms, a ring of a hundred thousand processes, a word counter with tests. Read it in order if the language is new to you. If you have written some Erlang, the sidebar is a menu, and OTP, distribution and hot code loading all stand on their own.

io:format("~s~n", [Text]).

Install Erlang and open the shell

Ten minutes to a working toolchain, and then most of your learning happens at a prompt that is also a running virtual machine.

Erlang ships as one package: the compiler, the runtime, the shell and the whole of OTP. There is no separate standard library to install and no framework to choose. The one extra thing worth having is rebar3, the build tool.

install.sh
# The version manager everyone ends up using. It handles Erlang and Elixir
# side by side and keeps a per project version in a .tool-versions file.
asdf plugin add erlang
asdf install erlang 27.2
asdf global erlang 27.2

# Or through a package manager, if you do not need to switch versions.
#   macOS         brew install erlang rebar3
#   Debian        apt install erlang
#   Fedora        dnf install erlang
#   Windows       winget install Erlang.ErlangOTP

# rebar3 is the build tool. It is a single self contained escript.
curl -fsSL -o rebar3 https://s3.amazonaws.com/rebar3/rebar3
chmod +x rebar3 && mv rebar3 ~/.local/bin/

erl -version
rebar3 --version
  1. Install Erlang/OTP

    Any recent version will do. OTP 27 brought documentation attributes and JSON in the standard library; OTP 26 and 25 are still widely deployed. If you expect to work on more than one project, install through asdf so versions can differ per directory.

  2. Install rebar3

    It is a single self contained script. It fetches dependencies from Hex, compiles, runs tests, runs Dialyzer and builds releases.

  3. Set your editor up

    erlang_ls or the newer ELP from WhatsApp both speak the language server protocol. VS Code, Neovim and Emacs all have working clients. Hover types and go to definition matter more here than in most languages, because so much of the API lives in modules named after the thing rather than the verb.

Your first module

hello.erl
%% Every file is a module, and the module name must match the file name.
-module(hello).

%% Only exported functions can be called from outside. The number after the
%% slash is the arity: how many arguments the function takes.
-export([start/0]).

start() ->
    io:format("Hello from the BEAM.~n"),
    io:format("This node is ~p and I am ~p.~n", [node(), self()]).
Compiling and running
# Start the shell. This boots a whole virtual machine, so everything you can
# do in production you can do here.
erl

# Compile a file from the command line instead.
erlc hello.erl        # produces hello.beam
erl -noshell -s hello start -s init stop

# A standalone script, with no compile step and no project around it.
cat > greet.escript <<'SCRIPT'
#!/usr/bin/env escript
main([Name]) -> io:format("hello ~s~n", [Name]);
main(_) -> io:format("usage: greet NAME~n").
SCRIPT
chmod +x greet.escript && ./greet.escript world

The shell is a running system

erl does not start a read eval print loop that pretends to be a program. It boots the actual virtual machine, with a scheduler per core, a garbage collector, and every OTP application available. Anything you can do in production you can do here, including connecting to a production node and looking around.

erl
1> 2 + 2 * 10.
22
2> X = 42.
42
3> X = 43.
** exception error: no match of right hand side value 43
4> f(X).
ok
5> X = 43.
43
6> Person = #{name => <<"Ada">>, age => 36}.
#{age => 36,name => <<"Ada">>}
7> maps:get(name, Person).
<<"Ada">>
8> c(counter).
{ok,counter}
9> Pid = counter:start().
<0.94.0>
10> counter:add(Pid, 5).
ok
11> counter:value(Pid).
5
12> i().
13> q().
CommandWhat it does
c(module).compile and load a module from the current directory
f().forget every variable binding, since you cannot rebind one
f(X).forget just X
b().show the current bindings
i().list every process, with its memory and message queue
flush().print and discard the shell process's mailbox
rr(module).load a module's record definitions so records print properly
observer:start().the graphical system monitor, worth opening once early
help().the full list
q().stop the node, gracefully
X = 42.

The syntax table

The translations worth keeping open for the first week. Everything in the Erlang column compiles.

Most of the strangeness comes from three decisions. Variables start with a capital letter and are bound exactly once. Anything starting with a lowercase letter is an atom, a constant whose name is its value. And punctuation carries the structure: a comma means and then, a semicolon means or, a period means the end.

IdeaErlangPythonJavaScript
VariableCount = 42count = 42const count = 42
Rebinding itnot allowedcount = 43let count = 43
Constant nameok, error, undefinedok = "ok"Symbol("ok")
DecimalRatio = 3.14ratio = 3.14const ratio = 3.14
Text, quickLabel = "hi"label = "hi"const label = "hi"
Text, for realText = <<"hi">>text = b"hi"Buffer.from("hi")
Fixed recordResult = {ok, 200}result = ("ok", 200)const result = ["ok", 200]
ListNums = [1, 2, 3]nums = [1, 2, 3]const nums = [1, 2, 3]
DictionaryM = #{id => 1}m = {"id": 1}const m = {id: 1}
Read a keymaps:get(id, M)m["id"]m.id
Functionadd(X, Y) -> X + Y.def add(x, y): return x + yconst add = (x, y) => x + y
Calling itadd(2, 3)add(2, 3)add(2, 3)
Anonymous functionfun(X) -> X * 2 endlambda x: x * 2(x) => x * 2
Concurrency unitspawn(fun worker/0)threading.Thread(...)new Worker(...)
Process handlePid = self()os.getpid()process.pid
Sending a messagePid ! {add, 1}queue.put(("add", 1))worker.postMessage(...)

Every line of the Erlang column is taken from this module, which compiles as it stands.

rosetta.erl
%% Every value in the comparison table, in one module that compiles.
-module(rosetta).
-export([examples/0]).

-record(user, {id, name, admin = false}).

examples() ->
    Count = 42,
    Ratio = 3.14,

    %% An atom is a constant whose name is its value. Lowercase.
    Status = ok,

    %% A string is a list of integers. Convenient, and slow for big text.
    Label = "hi",

    %% A binary is a packed byte array. This is what you want for real text.
    Text = <<"hi">>,

    %% A tuple is a fixed size record of values, usually tagged with an atom.
    Result = {ok, 200},

    %% A list is a linked list. Any element type, and mixing them is allowed.
    Nums = [1, 2, 3],

    %% A map is the dictionary. => inserts or updates, := only updates.
    Ages = #{ada => 36, grace => 45},

    %% A record is a tuple with named fields, resolved at compile time.
    User = #user{id = 1, name = <<"Ada">>},

    %% A process identifier. There is no equivalent in Python or JavaScript.
    Me = self(),

    %% A function value. fun with an end, or a reference to a named function.
    Double = fun(X) -> X * 2 end,
    ToList = fun erlang:integer_to_list/1,

    {Count, Ratio, Status, Label, Text, Result, Nums, Ages, User, Me,
     Double(21), ToList(7)}.

The punctuation, once and for all

MarkMeansExample
,and then, between expressionsA = 1, B = 2, A + B
;or, between clausesf(0) -> zero;
f(N) -> other.
.this form is finishedf(N) -> N * 2.
->the body of a clause followscase X of ok -> done end
=match these two, binding what is unbound{ok, V} = Result
!send a messagePid ! hello
|the rest of a list[Head | Tail]
=>put in a map, whether or not the key existsM#{key => 1}
:=update a map, and crash if the key is missingM#{key := 2}
#{name => <<"Ada">>, age => 36}

Terms: atoms, tuples, maps and binaries

Erlang has a small set of data types and no classes. Learning which one to reach for is most of learning the language.

Everything in Erlang is a term. Terms can be sent between processes, written to disk and copied to another machine without any conversion step, which is why the set is deliberately small.

atom
A constant whose name is its value: ok, error, undefined. One word of memory, and comparing two is a pointer comparison. Atoms are never garbage collected, so never turn user input into one with list_to_atom.
tuple
A fixed size group, read by position. Tagging the first slot with an atom, as in {ok, Value}, is the convention that the whole ecosystem follows.
list
A linked list. Cheap at the front, expensive at the back, and the natural thing to recurse over.
map
The dictionary. Any term can be a key. Use it for anything whose shape changes or crosses a boundary.
binary
A packed byte array, written <<"like this">>. This is what real text and all network data should be.
pid
A process identifier. It can be sent in a message, stored in a map, and used to reach a process on another machine.
reference
A value from make_ref/0 that is unique across the cluster. Used to match a reply to the request that asked for it.
fun
A function value, which closes over the variables around it.
terms.erl
-module(terms).
-export([atoms/0, numbers/0, strings/0, tuples/0, lists_of/0, maps_of/0,
         binaries/0, comparison/0]).

%% Atoms are constants. They cost one word and comparing them is a pointer
%% comparison, which is why Erlang tags everything with them.
atoms() ->
    [ok, error, undefined, 'an atom with spaces', true, false].

%% Integers are arbitrary precision. There is no overflow to worry about.
numbers() ->
    Big = 1 bsl 200,
    [17, -3, 2#1010, 16#ff, $A, 3.5, Big div 3].

%% "abc" is exactly [97, 98, 99]. That is worth knowing before it surprises
%% you in the shell.
strings() ->
    Chars = "abc",
    Same = [$a, $b, $c],
    {Chars =:= Same, length(Chars)}.

%% Tuples are fixed size and fast to read by position. Tagging them with an
%% atom in the first slot is the convention that makes matching readable.
tuples() ->
    Point = {point, 3, 4},
    {point, X, Y} = Point,
    {X, Y, element(2, Point), tuple_size(Point)}.

lists_of() ->
    L = [1, 2, 3],
    { [0 | L]              %% cons, cheap
    , L ++ [4]             %% append, walks the left list, so avoid in a loop
    , lists:reverse(L)
    , lists:sum(L)
    , length(L)
    }.

%% Maps are the modern dictionary. => works whether or not the key exists,
%% := demands that it does, which turns a typo into an immediate crash.
maps_of() ->
    M = #{name => <<"Ada">>, age => 36},
    Older = M#{age := 37},
    WithEmail = M#{email => <<"ada@example.com">>},
    { maps:get(name, M)
    , maps:get(nickname, M, none)   %% with a default
    , maps:is_key(age, M)
    , maps:keys(Older)
    , maps:size(WithEmail)
    }.

%% Binaries are contiguous memory. Pattern matching on them is how Erlang
%% parses network protocols in a few lines.
binaries() ->
    Bin = <<"hello world">>,
    <<First:5/binary, " ", Rest/binary>> = Bin,
    Packet = <<1:8, 4096:16, 7:8>>,
    <<Version:8, Length:16, Type:8>> = Packet,
    {First, Rest, byte_size(Bin), Version, Length, Type}.

%% Every term is comparable, and the order between types is fixed:
%% number < atom < reference < fun < port < pid < tuple < map < list < binary
comparison() ->
    lists:sort([<<"bin">>, [1, 2], {a, b}, ok, 42, #{}]).

Strings are the one real trap

"abc" is exactly [97, 98, 99]. That is convenient for parsing a few characters and wasteful for anything larger: eight bytes per character on a 64 bit machine, scattered across the heap.

Binaries are contiguous and shareable between processes without copying above 64 bytes. Modern Erlang uses them for text everywhere, and the string module works on both.

TaskUse
A literal<<"hello">>
Join<<A/binary, B/binary>> or iolist_to_binary([A, B])
Length in bytesbyte_size(Bin)
Splitbinary:split(Bin, <<",">>, [global])
Wordsstring:lexemes(Bin, " \n")
Casestring:lowercase(Bin), string:uppercase(Bin)
To a numberbinary_to_integer(Bin)
Formattingio_lib:format("~p", [Term])

Building output without joining strings

An iolist is a nested list of binaries, characters and other iolists. Every IO function accepts one, and the runtime walks it while writing rather than flattening it first. Appending to an iolist is putting it in a new list, so building a large response costs nothing until it is sent.

In practice this means never writing A ++ B to join text. Write [A, B] and hand that to file:write/2 or gen_tcp:send/2.

{ok, Value} = Result.

Pattern matching, and why = is not assignment

One operator that binds, tests and destructures at the same time. It is the feature everything else in the language is built on.

= is the match operator. It says the left side and the right side describe the same value, binds any variables that are still unbound, and crashes if that cannot be made true. It is closer to an equation than to an assignment.

That single operator does the work of destructuring, type checking, conditionals and assertions in other languages, and it appears in function heads, case expressions and receive clauses alike.

matching.erl
-module(matching).
-export([bind/0, destructure/1, http_message/1, describe/1, first_error/1,
         area/1]).

%% = is not assignment. It asserts that both sides match, and binds any
%% unbound variables on the way. Binding a variable twice to different values
%% is a badmatch error, not an overwrite.
bind() ->
    X = 1,
    1 = X,          %% fine, both sides are 1
    {ok, Value} = {ok, 42},
    Value.

%% Taking a value apart is the same operation as testing its shape.
destructure(Response) ->
    case Response of
        {ok, #{body := Body, status := 200}} -> {success, Body};
        {ok, #{status := Status}} -> {unexpected, Status};
        {error, timeout} -> retry;
        {error, Reason} -> {give_up, Reason}
    end.

%% Function clauses are pattern matching spread across several definitions.
%% The runtime tries them top to bottom.
http_message(200) -> "OK";
http_message(404) -> "Not Found";
http_message(500) -> "Server Error";
http_message(Code) when is_integer(Code) -> "Status " ++ integer_to_list(Code).

%% Matching on list shapes. [H | T] splits off the first element.
describe([]) -> "nothing";
describe([X]) -> "just " ++ X;
describe([X, Y]) -> X ++ " and " ++ Y;
describe([X | Rest]) -> X ++ " and " ++ integer_to_list(length(Rest)) ++ " more".

%% A variable used twice in one pattern means "these two must be equal".
%% Nothing else in mainstream languages does this.
first_error([{error, Reason}, {error, Reason} | _]) -> {repeated, Reason};
first_error([{error, Reason} | _]) -> {first, Reason};
first_error([_ | Rest]) -> first_error(Rest);
first_error([]) -> none.

%% Guards restrict a clause further. Only a fixed set of side effect free
%% tests is allowed in one, which is why they can never fail halfway.
area({circle, R}) when R > 0 -> math:pi() * R * R;
area({rectangle, W, H}) when W > 0, H > 0 -> W * H;
area({square, S}) when S > 0 -> S * S.
Matching at the prompt
1> {ok, Value} = {ok, 42}.
{ok,42}
2> Value.
42
3> {ok, Other} = {error, timeout}.
** exception error: no match of right hand side value {error,timeout}
4> [Head | Tail] = [1, 2, 3].
[1,2,3]
5> {Head, Tail}.
{1,[2,3]}
6> <<Version:8, Rest/binary>> = <<1, "payload">>.
<<1,112,97,121,108,111,97,100>>
7> {Version, Rest}.
{1,<<"payload">>}
8> #{name := Name} = #{name => <<"Ada">>, age => 36}.
#{age => 36,name => <<"Ada">>}
9> Name.
<<"Ada">>
10> [First, First | _] = [a, a, b].
[a,a,b]
11> [First, First | _] = [a, b, c].
** exception error: no match of right hand side value [a,b,c]
Python 3.12
def describe(items: list[str]) -> str:
    match items:
        case []:
            return "nothing"
        case [x]:
            return f"just {x}"
        case [x, y]:
            return f"{x} and {y}"
        case [x, *rest]:
            return f"{x} and {len(rest)} more"
    return "unreachable"


def http_message(code: int) -> str:
    match code:
        case 200:
            return "OK"
        case 404:
            return "Not Found"
        case 500:
            return "Server Error"
        case _:
            return f"Status {code}"


def distance(a: tuple[float, float], b: tuple[float, float]) -> float:
    (x1, y1), (x2, y2) = a, b
    return ((x2 - x1) ** 2 + (y2 - y1) ** 2) ** 0.5


def grade(score: int) -> str:
    if score >= 90:
        return "A"
    if score >= 80:
        return "B"
    if score >= 70:
        return "C"
    return "F"
JavaScript (Node 22)
// There is no pattern matching, so shapes are checked by hand.
export function describe(items) {
  if (items.length === 0) return "nothing";
  if (items.length === 1) return `just ${items[0]}`;
  if (items.length === 2) return `${items[0]} and ${items[1]}`;
  const [first, ...rest] = items;
  return `${first} and ${rest.length} more`;
}

export function httpMessage(code) {
  switch (code) {
    case 200:
      return "OK";
    case 404:
      return "Not Found";
    case 500:
      return "Server Error";
    default:
      return `Status ${code}`;
  }
}

export function distance([x1, y1], [x2, y2]) {
  return Math.hypot(x2 - x1, y2 - y1);
}

export function grade(score) {
  if (score >= 90) return "A";
  if (score >= 80) return "B";
  if (score >= 70) return "C";
  return "F";
}

Python's match statement, added in 3.10, is the closest equivalent. JavaScript has destructuring but no matching, so shapes are checked by hand.

Single assignment is a feature

A variable is bound once and never changes. That sounds restrictive for about a day, and then it becomes the reason you can read a function from top to bottom and know what everything holds. There is no earlier line that might have changed Count, because there cannot be.

The usual objection is loops, and the answer is that a loop with a changing counter becomes a function with a parameter. The recursion chapter is entirely about that translation.

Try it yourself

Write first_error/1, which walks a list of results and returns {first, Reason} for the first error, or {repeated, Reason} if the first two errors share a reason, or none if there are no errors at all.

Hint: Use the same variable name twice in one pattern to say the two reasons must be equal.

Show one solution
matching.erl
-module(matching).
-export([bind/0, destructure/1, http_message/1, describe/1, first_error/1,
         area/1]).

%% = is not assignment. It asserts that both sides match, and binds any
%% unbound variables on the way. Binding a variable twice to different values
%% is a badmatch error, not an overwrite.
bind() ->
    X = 1,
    1 = X,          %% fine, both sides are 1
    {ok, Value} = {ok, 42},
    Value.

%% Taking a value apart is the same operation as testing its shape.
destructure(Response) ->
    case Response of
        {ok, #{body := Body, status := 200}} -> {success, Body};
        {ok, #{status := Status}} -> {unexpected, Status};
        {error, timeout} -> retry;
        {error, Reason} -> {give_up, Reason}
    end.

%% Function clauses are pattern matching spread across several definitions.
%% The runtime tries them top to bottom.
http_message(200) -> "OK";
http_message(404) -> "Not Found";
http_message(500) -> "Server Error";
http_message(Code) when is_integer(Code) -> "Status " ++ integer_to_list(Code).

%% Matching on list shapes. [H | T] splits off the first element.
describe([]) -> "nothing";
describe([X]) -> "just " ++ X;
describe([X, Y]) -> X ++ " and " ++ Y;
describe([X | Rest]) -> X ++ " and " ++ integer_to_list(length(Rest)) ++ " more".

%% A variable used twice in one pattern means "these two must be equal".
%% Nothing else in mainstream languages does this.
first_error([{error, Reason}, {error, Reason} | _]) -> {repeated, Reason};
first_error([{error, Reason} | _]) -> {first, Reason};
first_error([_ | Rest]) -> first_error(Rest);
first_error([]) -> none.

%% Guards restrict a clause further. Only a fixed set of side effect free
%% tests is allowed in one, which is why they can never fail halfway.
area({circle, R}) when R > 0 -> math:pi() * R * R;
area({rectangle, W, H}) when W > 0, H > 0 -> W * H;
area({square, S}) when S > 0 -> S * S.
classify(N) when N < 0 -> negative;

Modules, clauses and guards

Functions are defined by several clauses tried in order, identified by name and arity, and grouped into modules that export what the world may call.

Every function lives in a module and is known by its name and its arity together. area/2 and area/3 are two unrelated functions, and only what appears in the -export attribute can be called from outside.

functions.erl
-module(functions).
-export([double/1, area/2, classify/1, bmi_tell/2, cylinder_area/2,
         add_one_to_all/1, apply_twice/2, make_adder/1, pipeline/1]).

double(X) -> X * 2.

%% Arguments are positional and the arity is part of the name. area/2 and
%% area/3 are two different functions that happen to share a spelling.
area(Width, Height) -> Width * Height.

%% Guards choose between clauses. They run left to right and the first that
%% holds wins. A comma in a guard means and, a semicolon means or.
classify(N) when N < 0 -> negative;
classify(0) -> zero;
classify(N) when N < 10 -> small;
classify(_) -> large.

%% There is no `where`. Intermediate values are just variables, and since
%% each one is bound exactly once, reading top to bottom always tells the
%% truth about what they hold.
bmi_tell(Weight, Height) ->
    Bmi = Weight / (Height * Height),
    Verdict =
        if
            Bmi =< 18.5 -> underweight;
            Bmi =< 25.0 -> normal;
            true -> overweight      %% `true` is the else branch
        end,
    {Verdict, Bmi}.

cylinder_area(Radius, Height) ->
    Side = 2 * math:pi() * Radius * Height,
    Cap = math:pi() * Radius * Radius,
    Side + 2 * Cap.

add_one_to_all(Numbers) ->
    lists:map(fun(N) -> N + 1 end, Numbers).

%% Functions are values. A fun can be passed, stored in a map, or sent to
%% another process on another machine.
apply_twice(Fun, Value) -> Fun(Fun(Value)).

%% A fun closes over the variables around it, which is how you get something
%% that behaves like partial application.
make_adder(N) ->
    fun(X) -> X + N end.

%% There is no composition operator, so a pipeline is either nested calls or
%% a fold over a list of funs.
pipeline(Value) ->
    Steps = [fun(X) -> X * 2 end, fun(X) -> X + 1 end, fun integer_to_list/1],
    lists:foldl(fun(Step, Acc) -> Step(Acc) end, Value, Steps).
Python 3.12
def double(x: int) -> int:
    return x * 2


def area(width: float, height: float) -> float:
    return width * height


def classify(n: int) -> str:
    if n < 0:
        return "negative"
    if n == 0:
        return "zero"
    if n < 10:
        return "small"
    return "large"


def bmi_tell(weight: float, height: float) -> str:
    bmi = weight / height**2
    if bmi <= 18.5:
        return f"underweight, bmi {bmi}"
    if bmi <= 25.0:
        return f"normal, bmi {bmi}"
    return f"overweight, bmi {bmi}"


def cylinder_area(radius: float, height: float) -> float:
    side = 2 * 3.141592653589793 * radius * height
    cap = 3.141592653589793 * radius**2
    return side + 2 * cap


def add_one_to_all(xs: list[int]) -> list[int]:
    return [n + 1 for n in xs]
JavaScript (Node 22)
export const double = (x) => x * 2;

export const area = (width, height) => width * height;

export function classify(n) {
  if (n < 0) return "negative";
  if (n === 0) return "zero";
  if (n < 10) return "small";
  return "large";
}

export function bmiTell(weight, height) {
  const bmi = weight / height ** 2;
  if (bmi <= 18.5) return `underweight, bmi ${bmi}`;
  if (bmi <= 25.0) return `normal, bmi ${bmi}`;
  return `overweight, bmi ${bmi}`;
}

export function cylinderArea(radius, height) {
  const side = 2 * Math.PI * radius * height;
  const cap = Math.PI * radius ** 2;
  return side + 2 * cap;
}

export const addOneToAll = (xs) => xs.map((n) => n + 1);

Clauses instead of branches

Writing several clauses is the normal way to express a choice. The runtime tries them top to bottom and takes the first whose pattern matches and whose guard holds. If none of them do, the process crashes with function_clause, which is the right outcome: the caller passed something the function was never designed for, and continuing would be a guess.

Guards are a small, safe language

Only a fixed set of expressions is allowed in a guard: comparisons, arithmetic, boolean operators and the type tests. No function you wrote, no message sends, nothing that can have a side effect. That restriction is what lets the runtime evaluate guards while choosing a clause and never leave anything half done.

GuardTrue when
is_integer(X), is_float(X), is_number(X)the type is what it says
is_atom(X), is_binary(X), is_list(X), is_map(X)likewise
is_pid(X), is_reference(X), is_function(X, 2)likewise, with arity
X > 0, X =:= ok, X =/= undefinedcomparisons, strict and loose
length(L) > 2, map_size(M) =:= 0, byte_size(B) < 100size tests
A > 0, B > 0a comma means and
A > 0; B > 0a semicolon means or
lists:foldl(Fun, Acc0, List) -> Acc.

Lists and comprehensions

Linked lists, the lists module, and comprehensions that read the same way Python's do.

A list is a linked list. [Head | Tail] is both how you build one and how you take it apart, and that symmetry is why so much Erlang code is a recursion over a list.

Adding to the front is constant time. Appending with ++ copies the whole left hand list, so building a result by appending in a loop is quadratic. Build it backwards with [X | Acc] and call lists:reverse/1 once at the end.

lists_demo.erl
-module(lists_demo).
-export([basics/0, comprehensions/0, pythagorean/0, initials/1, folds/1,
         keyfind_demo/0, higher_order/0]).

basics() ->
    Primes = [2, 3, 5, 7, 11],
    { [1 | Primes]                  %% cons is O(1)
    , lists:nth(2, Primes)          %% indexing is O(n), so avoid it in loops
    , lists:seq(1, 10)
    , lists:seq(10, 1, -1)
    , lists:sublist(Primes, 3)
    , lists:last(Primes)
    }.

%% A comprehension is a generator, some filters and an expression. It reads
%% the same way as the Python version and compiles to a plain recursion.
comprehensions() ->
    Squares = [N * N || N <- lists:seq(1, 10)],
    Evens = [N || N <- lists:seq(1, 20), N rem 2 =:= 0],

    %% A pattern in a generator quietly skips anything that does not match,
    %% which is the neatest way to filter and destructure at once.
    People = [{ada, 36}, {grace, 45}, unknown, {alan, 41}],
    Names = [Name || {Name, _Age} <- People],

    {Squares, Evens, Names}.

pythagorean() ->
    [{A, B, C}
     || C <- lists:seq(1, 20),
        B <- lists:seq(1, C),
        A <- lists:seq(1, B),
        A * A + B * B =:= C * C].

initials(Sentence) ->
    [Char || [Char | _] <- string:tokens(Sentence, " ")].

%% foldl is the workhorse. foldr exists but builds the whole call stack, so
%% for anything long reach for foldl and reverse at the end if order matters.
folds(Numbers) ->
    Sum = lists:foldl(fun(X, Acc) -> X + Acc end, 0, Numbers),
    Max = lists:foldl(fun erlang:max/2, hd(Numbers), Numbers),
    Reversed = lists:foldl(fun(X, Acc) -> [X | Acc] end, [], Numbers),
    {Sum, Max, Reversed}.

%% Lists of tagged tuples are everywhere in Erlang, and the keyX functions
%% are how you work with them.
keyfind_demo() ->
    People = [{ada, 36}, {grace, 45}],
    { lists:keyfind(ada, 1, People)
    , lists:keyfind(alan, 1, People)          %% false, not an exception
    , lists:keystore(alan, 1, People, {alan, 41})
    , lists:keydelete(ada, 1, People)
    , proplists:get_value(age, [{age, 36}], 0)
    }.

higher_order() ->
    Words = ["ada", "grace", "alan", "barbara"],
    { lists:map(fun string:to_upper/1, Words)
    , lists:filter(fun(W) -> length(W) =< 4 end, Words)
    , lists:any(fun(W) -> length(W) > 6 end, Words)
    , lists:all(fun(W) -> length(W) > 2 end, Words)
    , lists:sort(fun(A, B) -> length(A) =< length(B) end, Words)
    , lists:partition(fun(W) -> length(W) =< 4 end, Words)
    , lists:zip([1, 2, 3], [a, b, c])
    }.
Python 3.12
from itertools import count, islice

primes = [2, 3, 5, 7, 11]

# Python lists are arrays, so prepending copies the whole thing.
with_one = [1, *primes]

countdown = list(range(10, 0, -1))
evens = list(range(0, 21, 2))
letters = [chr(c) for c in range(ord("a"), ord("f"))]

# There is no lazy list literal, so an infinite sequence needs itertools.
squares = [n * n for n in islice(count(1), 10)]

pythagorean = [
    (a, b, c)
    for c in range(1, 21)
    for b in range(1, c + 1)
    for a in range(1, b + 1)
    if a * a + b * b == c * c
]


def initials(sentence: str) -> str:
    return "".join(word[0] for word in sentence.split())


def summary(xs: list[int]) -> tuple[int, int, list[int], list[int]]:
    total = sum(xs)
    # Slicing and reversed() copy, but sort() and append() do not: some list
    # methods return a new list and some change the one you have.
    return (len(xs), total, xs[:3], list(reversed(xs)))
JavaScript (Node 22)
export const primes = [2, 3, 5, 7, 11];

export const withOne = [1, ...primes];

export const countdown = Array.from({ length: 10 }, (_, i) => 10 - i);
export const evens = Array.from({ length: 11 }, (_, i) => i * 2);
export const letters = Array.from({ length: 5 }, (_, i) =>
  String.fromCharCode(97 + i),
);

// Nothing is lazy, so an endless sequence needs a generator.
function* naturals() {
  for (let n = 1; ; n++) yield n;
}

export const squares = [];
for (const n of naturals()) {
  if (squares.length === 10) break;
  squares.push(n * n);
}

export const pythagorean = [];
for (let c = 1; c <= 20; c++) {
  for (let b = 1; b <= c; b++) {
    for (let a = 1; a <= b; a++) {
      if (a * a + b * b === c * c) pythagorean.push([a, b, c]);
    }
  }
}

export const initials = (sentence) =>
  sentence
    .split(/\s+/)
    .filter(Boolean)
    .map((word) => word[0])
    .join("");

// sort() and reverse() change the array in place and also return it, which
// is the source of a great many surprises.
export const summary = (xs) => [xs.length, xs.reduce((a, b) => a + b, 0), xs.slice(0, 3), [...xs].reverse()];

Both need a generator to express an endless sequence. Erlang has no lazy lists either, so infinite sequences are a process that sends the next value when asked.

The lists module, by frequency of use

FunctionWhat it does
lists:map/2, lists:filter/2the obvious two
lists:foldl/3the workhorse. foldr exists and builds a deep stack
lists:foreach/2when you want the side effects and not the results
lists:sort/1, lists:sort/2merge sort, stable, with an optional comparator
lists:usort/1sort and remove duplicates in one pass
lists:keyfind/3, lists:keystore/4working with lists of tagged tuples
lists:member/2is it in there. Linear, so a map is often better
lists:seq/2, lists:seq/3ranges
lists:zip/2, lists:unzip/1pairing up and splitting apart
lists:partition/2, lists:splitwith/2one pass, two results
lists:flatten/1rarely what you want. Prefer an iolist
lists:sublist/2, lists:nth/2taking. nth is linear, so avoid it in a loop

Try it yourself

Write initials/1, which turns "ada lovelace king" into "alk", and make it survive extra spaces without crashing.

Hint: A pattern in a comprehension generator skips anything that does not match, including the empty string.

Show one solution
lists_demo.erl
-module(lists_demo).
-export([basics/0, comprehensions/0, pythagorean/0, initials/1, folds/1,
         keyfind_demo/0, higher_order/0]).

basics() ->
    Primes = [2, 3, 5, 7, 11],
    { [1 | Primes]                  %% cons is O(1)
    , lists:nth(2, Primes)          %% indexing is O(n), so avoid it in loops
    , lists:seq(1, 10)
    , lists:seq(10, 1, -1)
    , lists:sublist(Primes, 3)
    , lists:last(Primes)
    }.

%% A comprehension is a generator, some filters and an expression. It reads
%% the same way as the Python version and compiles to a plain recursion.
comprehensions() ->
    Squares = [N * N || N <- lists:seq(1, 10)],
    Evens = [N || N <- lists:seq(1, 20), N rem 2 =:= 0],

    %% A pattern in a generator quietly skips anything that does not match,
    %% which is the neatest way to filter and destructure at once.
    People = [{ada, 36}, {grace, 45}, unknown, {alan, 41}],
    Names = [Name || {Name, _Age} <- People],

    {Squares, Evens, Names}.

pythagorean() ->
    [{A, B, C}
     || C <- lists:seq(1, 20),
        B <- lists:seq(1, C),
        A <- lists:seq(1, B),
        A * A + B * B =:= C * C].

initials(Sentence) ->
    [Char || [Char | _] <- string:tokens(Sentence, " ")].

%% foldl is the workhorse. foldr exists but builds the whole call stack, so
%% for anything long reach for foldl and reverse at the end if order matters.
folds(Numbers) ->
    Sum = lists:foldl(fun(X, Acc) -> X + Acc end, 0, Numbers),
    Max = lists:foldl(fun erlang:max/2, hd(Numbers), Numbers),
    Reversed = lists:foldl(fun(X, Acc) -> [X | Acc] end, [], Numbers),
    {Sum, Max, Reversed}.

%% Lists of tagged tuples are everywhere in Erlang, and the keyX functions
%% are how you work with them.
keyfind_demo() ->
    People = [{ada, 36}, {grace, 45}],
    { lists:keyfind(ada, 1, People)
    , lists:keyfind(alan, 1, People)          %% false, not an exception
    , lists:keystore(alan, 1, People, {alan, 41})
    , lists:keydelete(ada, 1, People)
    , proplists:get_value(age, [{age, 36}], 0)
    }.

higher_order() ->
    Words = ["ada", "grace", "alan", "barbara"],
    { lists:map(fun string:to_upper/1, Words)
    , lists:filter(fun(W) -> length(W) =< 4 end, Words)
    , lists:any(fun(W) -> length(W) > 6 end, Words)
    , lists:all(fun(W) -> length(W) > 2 end, Words)
    , lists:sort(fun(A, B) -> length(A) =< length(B) end, Words)
    , lists:partition(fun(W) -> length(W) =< 4 end, Words)
    , lists:zip([1, 2, 3], [a, b, c])
    }.
sum_tail([H | T], Acc) -> sum_tail(T, H + Acc).

Recursion and tail calls

There is no for and no while, because there is nothing to increment. A loop becomes a function whose arguments carry the state.

This is the change that sounds largest and turns out to be smallest. A loop has a starting state, a rule for the next state, and a stopping condition. A recursive function has a base case and a case that makes the problem smaller, which is the same three things.

recursion.erl
-module(recursion).
-export([factorial/1, sum/1, sum_tail/1, reverse/1, quicksort/1, fib/1,
         collatz/1, count_down/1]).

%% The base case first, then the case that makes the problem smaller.
factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).

%% Body recursive: the addition happens after the recursive call returns, so
%% the stack grows with the length of the list.
sum([]) -> 0;
sum([Head | Tail]) -> Head + sum(Tail).

%% Tail recursive: the recursive call is the last thing that happens, so the
%% BEAM reuses the same stack frame. This runs in constant space over a list
%% of any length, and it is the shape almost all Erlang loops take.
sum_tail(Numbers) -> sum_tail(Numbers, 0).

sum_tail([], Acc) -> Acc;
sum_tail([Head | Tail], Acc) -> sum_tail(Tail, Head + Acc).

%% The accumulator replaces the variable you would have mutated. Building the
%% result backwards and reversing once at the end is idiomatic and fast.
reverse(List) -> reverse(List, []).

reverse([], Acc) -> Acc;
reverse([Head | Tail], Acc) -> reverse(Tail, [Head | Acc]).

%% Readable rather than fast, exactly like the Haskell one liner. For real
%% work call lists:sort/1, which is a well tuned merge sort.
quicksort([]) -> [];
quicksort([Pivot | Rest]) ->
    quicksort([X || X <- Rest, X =< Pivot])
        ++ [Pivot]
        ++ quicksort([X || X <- Rest, X > Pivot]).

fib(N) -> fib(N, 0, 1).

fib(0, A, _B) -> A;
fib(N, A, B) when N > 0 -> fib(N - 1, B, A + B).

collatz(1) -> [1];
collatz(N) when N rem 2 =:= 0 -> [N | collatz(N div 2)];
collatz(N) -> [N | collatz(3 * N + 1)].

%% A loop that only does side effects still returns something. ok is the
%% conventional "nothing useful to say" value.
count_down(0) ->
    io:format("liftoff~n"),
    ok;
count_down(N) when N > 0 ->
    io:format("~p...~n", [N]),
    count_down(N - 1).
Python 3.12
from functools import lru_cache
from itertools import islice


def factorial(n: int) -> int:
    return 1 if n == 0 else n * factorial(n - 1)


def my_sum(xs: list[int]) -> int:
    # Recursion over a list of more than about a thousand items hits
    # RecursionError, so real Python code writes the loop instead.
    if not xs:
        return 0
    return xs[0] + my_sum(xs[1:])


def my_reverse(xs: list[int]) -> list[int]:
    result: list[int] = []
    for x in xs:
        result.insert(0, x)
    return result


def quick_sort(xs: list[int]) -> list[int]:
    if not xs:
        return []
    pivot, rest = xs[0], xs[1:]
    smaller = [x for x in rest if x <= pivot]
    larger = [x for x in rest if x > pivot]
    return quick_sort(smaller) + [pivot] + quick_sort(larger)


def fibs():
    a, b = 0, 1
    while True:
        yield a
        a, b = b, a + b


first_ten_fibs = list(islice(fibs(), 10))


@lru_cache(maxsize=None)
def collatz_length(n: int) -> int:
    if n == 1:
        return 1
    return 1 + collatz_length(n // 2 if n % 2 == 0 else 3 * n + 1)
JavaScript (Node 22)
export const factorial = (n) => (n === 0 ? 1n : BigInt(n) * factorial(n - 1));

export function mySum(xs) {
  // No tail call optimisation in practice, so deep recursion overflows the
  // stack and real code writes a loop.
  if (xs.length === 0) return 0;
  const [first, ...rest] = xs;
  return first + mySum(rest);
}

export function myReverse(xs) {
  let result = [];
  for (const x of xs) result = [x, ...result];
  return result;
}

export function quickSort(xs) {
  if (xs.length === 0) return [];
  const [pivot, ...rest] = xs;
  const smaller = rest.filter((x) => x <= pivot);
  const larger = rest.filter((x) => x > pivot);
  return [...quickSort(smaller), pivot, ...quickSort(larger)];
}

export function* fibs() {
  let [a, b] = [0, 1];
  for (;;) {
    yield a;
    [a, b] = [b, a + b];
  }
}

export function collatz(n) {
  const path = [n];
  while (n !== 1) {
    n = n % 2 === 0 ? n / 2 : 3 * n + 1;
    path.push(n);
  }
  return path;
}

Both hit a stack limit long before the data does, which is why the idiomatic versions use loops. The BEAM turns a tail call into a jump, so the Erlang version has no such ceiling.

Tail calls are the whole trick

When the recursive call is the last thing a clause does, the runtime reuses the current stack frame instead of pushing a new one. The function runs in constant space no matter how long the list is, and it compiles to something very close to a loop.

The difference is visible in the two sums above. sum/1 has to remember to add Head after the recursive call returns, so it builds a frame per element. sum_tail/2 does the addition on the way in and carries the answer along, so it builds none.

This matters more than usual in Erlang, because a process loop is an infinite tail call. A server that has handled ten million messages is still on its first stack frame.

User#user{admin = true}

Records and maps

Two ways to hold structured data, with different tradeoffs. Records are a compile time convenience over tuples; maps are real runtime dictionaries.

A record is a tuple with named fields. The names exist only at compile time: #user{id = 1} becomes {user, 1, undefined, false} in the compiled code, and reading a field is element/2. That makes them fast and rigid.

A map is a real dictionary, checked at runtime, able to gain and lose keys. It is what you want for anything whose shape varies or that crosses a boundary into JSON, another node, or a database.

shapes.erl
-module(shapes).
-export([new_user/2, promote/1, describe/1, update_settings/2, totals/1,
         to_map/1, from_map/1]).

%% A record is a tuple with named fields. The names are erased at compile
%% time, so access is as fast as element/2 and costs nothing at runtime.
-record(user, {id, name, email, admin = false}).

%% A map is checked at runtime and can grow new keys. Records are for fixed
%% internal structures, maps for anything that crosses a boundary.
new_user(Id, Name) ->
    #user{id = Id, name = Name, email = undefined}.

%% Updating a record copies it with some fields changed. The original is
%% still there, unchanged, for anyone else holding it.
promote(User) ->
    User#user{admin = true}.

describe(#user{name = Name, admin = true}) ->
    io_lib:format("~s (admin)", [Name]);
describe(#user{name = Name}) ->
    io_lib:format("~s", [Name]).

%% := requires the key to exist already, so a typo crashes here instead of
%% quietly adding a field nobody reads.
update_settings(Settings, Changes) ->
    maps:fold(fun(Key, Value, Acc) -> Acc#{Key := Value} end, Settings, Changes).

totals(Orders) ->
    lists:foldl(
        fun(#{item := Item, cost := Cost}, Acc) ->
            maps:update_with(Item, fun(Old) -> Old + Cost end, Cost, Acc)
        end,
        #{},
        Orders
    ).

%% Converting between the two is a couple of lines, and worth writing at the
%% edges of a system so records stay internal.
to_map(#user{id = Id, name = Name, email = Email, admin = Admin}) ->
    #{id => Id, name => Name, email => Email, admin => Admin}.

from_map(#{id := Id, name := Name} = Map) ->
    #user{
        id = Id,
        name = Name,
        email = maps:get(email, Map, undefined),
        admin = maps:get(admin, Map, false)
    }.
Python 3.12
from dataclasses import dataclass, replace
from enum import Enum, auto
from math import pi, sqrt


class Direction(Enum):
    NORTH = auto()
    SOUTH = auto()
    EAST = auto()
    WEST = auto()


@dataclass(frozen=True)
class Circle:
    radius: float


@dataclass(frozen=True)
class Rectangle:
    width: float
    height: float


@dataclass(frozen=True)
class Triangle:
    a: float
    b: float
    c: float


Shape = Circle | Rectangle | Triangle


def area(shape: Shape) -> float:
    # A missing branch is a runtime surprise unless a type checker is run
    # separately. The Erlang compiler warns, and a missing clause crashes the process rather than the system.
    match shape:
        case Circle(radius):
            return pi * radius * radius
        case Rectangle(width, height):
            return width * height
        case Triangle(a, b, c):
            s = (a + b + c) / 2
            return sqrt(s * (s - a) * (s - b) * (s - c))
    raise ValueError(f"unknown shape: {shape}")


@dataclass(frozen=True)
class Employee:
    name: str
    email: str
    salary: int


def raise_salary(amount: int, employee: Employee) -> Employee:
    # frozen=True plus replace() is the closest Python gets to Erlang record update.
    return replace(employee, salary=employee.salary + amount)
JavaScript (Node 22)
export const Direction = Object.freeze({
  North: "North",
  South: "South",
  East: "East",
  West: "West",
});

// A tag field stands in for a real sum type.
export const circle = (radius) => ({ kind: "circle", radius });
export const rectangle = (width, height) => ({ kind: "rectangle", width, height });
export const triangle = (a, b, c) => ({ kind: "triangle", a, b, c });

export function area(shape) {
  switch (shape.kind) {
    case "circle":
      return Math.PI * shape.radius ** 2;
    case "rectangle":
      return shape.width * shape.height;
    case "triangle": {
      const s = (shape.a + shape.b + shape.c) / 2;
      return Math.sqrt(s * (s - shape.a) * (s - shape.b) * (s - shape.c));
    }
    // Forget a case and the answer is quietly undefined.
    default:
      throw new Error(`unknown shape: ${shape.kind}`);
  }
}

// Spread copies one level. Anything nested is still shared with the
// original, which is where "why did that change" bugs come from.
export const raiseSalary = (amount, employee) => ({
  ...employee,
  salary: employee.salary + amount,
});

A frozen dataclass is the closest Python equivalent to a record, and replace() is the closest thing to record update syntax.

RecordMap
Definedat compile time, in a .hrl or modulenever, it is just data
Field accessUser#user.name, constant timemaps:get(name, M), near constant
Unknown fieldcompile errorruntime error, or a default
New field laterrecompile every module that uses itjust put it in
Pattern matching#user{name = N}#{name := N}
Prints asa tuple, unless the shell knows the recordreadably
Good forhot internal state, fixed shapesconfig, JSON, messages, anything shared
try Expr catch error:Reason:Stack -> ... end

Errors, exits and let it crash

Erlang has exceptions and mostly does not use them. Expected failures are return values, unexpected ones kill the process, and something else is responsible for what happens next.

The phrase let it crash gets quoted so often that it starts to sound like carelessness. It is the opposite. It means: handle the cases you genuinely understand, and for everything else, stop immediately rather than carry on in a state you did not design for.

That is only reasonable because a crash in Erlang is small. One process dies. It has its own heap, so nothing it was holding is left half updated, and something above it is already responsible for starting a clean replacement.

errors.erl
-module(errors).
-export([tagged/1, raising/1, catching/1, cleanup/1, custom/1, crash_it/0]).

%% Style one, and the one to reach for first: return a tagged tuple. An
%% expected failure is a value, so the caller has to match on it.
tagged(Input) ->
    case string:to_integer(Input) of
        {error, Reason} -> {error, Reason};
        {Number, _Rest} when Number < 0 -> {error, negative};
        {Number, _Rest} -> {ok, Number}
    end.

%% Style two: raise. There are three classes, and they mean different things.
%%   error : a bug. Something the code did not expect.
%%   exit  : this process is finished, deliberately.
%%   throw : a non local return, for control flow inside one module.
raising(bug) -> error(badarg);
raising(done) -> exit(normal);
raising(escape) -> throw({not_found, here});
raising(fine) -> ok.

%% try catches all three, and the pattern is Class:Reason:Stacktrace.
catching(Input) ->
    try raising(Input) of
        Result -> {returned, Result}
    catch
        error:Reason:Stack ->
            {caught_error, Reason, length(Stack)};
        exit:Reason ->
            {caught_exit, Reason};
        throw:Thrown ->
            {caught_throw, Thrown}
    end.

%% after always runs, whether or not something was raised. Use it to close
%% what you opened.
cleanup(Path) ->
    {ok, Handle} = file:open(Path, [read]),
    try
        file:read_line(Handle)
    after
        file:close(Handle)
    end.

%% Attaching context to a raise. The stacktrace keeps the original site.
custom(Value) ->
    try
        check(Value)
    catch
        error:Reason:Stack ->
            erlang:raise(error, {validation_failed, Value, Reason}, Stack)
    end.

check(Value) when is_integer(Value), Value > 0 -> ok;
check(_) -> error(not_a_positive_integer).

%% The fourth option, and often the right one: do not handle it. Let the
%% process die and let its supervisor start a clean one. Code that only
%% handles the cases it understands stays much shorter.
crash_it() ->
    {ok, Value} = {error, whatever},
    Value.
Python 3.12
class ValidationError(ValueError):
    """Raised for input that cannot be used."""


def parse_age(raw: str) -> int:
    # The failure is invisible from the outside. Nothing in the signature
    # says this can raise, and nothing makes the caller catch it.
    try:
        n = int(raw)
    except ValueError as problem:
        raise ValidationError(f"not a number: {raw}") from problem
    if n < 0:
        raise ValidationError(f"negative: {n}")
    return n


def process(rows: list[str]) -> list[int]:
    results = []
    for row in rows:
        try:
            results.append(parse_age(row))
        except ValidationError as problem:
            # The choice is between swallowing it and losing information, or
            # letting it propagate and taking down whatever was above.
            print(f"skipping: {problem}")
    return results


def worker() -> None:
    # A thread that raises dies quietly. Nothing restarts it, nothing tells
    # anyone, and the pool is one worker smaller until someone notices.
    raise RuntimeError("connection lost")
JavaScript (Node 22)
export class ValidationError extends Error {}

export function parseAge(raw) {
  // Nothing in the signature says this throws, and nothing forces a catch.
  const n = Number(raw);
  if (!Number.isInteger(n)) throw new ValidationError(`not a number: ${raw}`);
  if (n < 0) throw new ValidationError(`negative: ${n}`);
  return n;
}

export function process(rows) {
  const results = [];
  for (const row of rows) {
    try {
      results.push(parseAge(row));
    } catch (problem) {
      console.warn(`skipping: ${problem.message}`);
    }
  }
  return results;
}

// An unhandled rejection ends the process by default in modern Node, so one
// forgotten await in one request handler takes down every other request in
// flight with it.
export async function worker() {
  throw new Error("connection lost");
}

In both, an unhandled failure takes down the worker or the whole process, and nothing built into the language decides what happens next.

Three classes, three meanings

ClassRaised byMeans
errorerror(Reason), or a bad match, or a bad argumenta bug. The code met something it was not written for
exitexit(Reason)this process is finished, deliberately
throwthrow(Term)a non local return, for control flow inside one module

The distinction matters because links and monitors carry the reason, and supervisors treat normal and shutdown differently from everything else. A process that exits with normal has finished its job; one that exits with badarg has failed.

Which style, when

return it

For failures you expect

A missing key, a malformed request, a refused connection. Return {ok, Value} or {error, Reason} and let the caller match on it. This is what almost all of OTP does, which is why the tagged tuple is so recognisable.

crash

For failures you do not

A response that does not have the shape the protocol promised. Write the match you expect, and if it fails, the process dies with a clear badmatch naming exactly what arrived. No error handling code to write, and none to get wrong.

catch

At a boundary

At the edge of a request handler, or around a call into code you do not control, where you want to turn a crash into a 500 rather than lose the connection. Catching in the middle of your own logic is usually a sign the logic should be in its own process.

after

For cleanup

try ... after ... end runs the after block whether or not something was raised. Use it for file handles and sockets. Note that if the process is killed outright, after does not run, which is one more reason resources belong to the process that owns them.

Pid ! {increment, 1}.

Processes, mailboxes and receive

The actor model, in the language rather than in a library. Spawn is cheap, sending never blocks, and state lives in a loop instead of a variable.

An Erlang process is not a thread. The virtual machine schedules it itself, gives it its own heap and its own garbage collector, and preempts it after a fixed amount of work, so no process can starve another by running a long loop.

Creating one costs a few microseconds and about 300 words. That price is what makes the standard design possible: a process per connection, per chat room, per job, per state machine, rather than a pool of workers shared between them.

processes.erl
-module(processes).
-export([start/0, loop/1, call/2, cast/2, ping_pong/0, pong/0, spawn_many/1]).

%% spawn returns a process identifier immediately. The new process runs
%% ?MODULE:loop(0) and has its own heap, its own stack and its own mailbox.
%% It costs about 300 words to create, so making a million of them is a
%% design decision rather than a stunt.
start() ->
    spawn(?MODULE, loop, [0]).

%% This is the entire pattern for stateful code in Erlang: a function that
%% waits for a message, works out the new state, and calls itself with it.
%% The state is an argument, so nothing else can reach in and change it.
loop(Count) ->
    receive
        {increment, N} ->
            loop(Count + N);
        {value, From, Ref} ->
            From ! {reply, Ref, Count},
            loop(Count);
        stop ->
            ok
    after 300000 ->
        %% An idle timeout. Without one, a forgotten process waits forever.
        io:format("counter idle for five minutes, stopping~n"),
        ok
    end.

%% Asking for an answer means sending a message and waiting for the reply.
%% The unique reference is what stops an old, late reply from being mistaken
%% for this one, and the timeout is what stops the caller hanging forever.
%% Getting all three details right every time is why gen_server exists.
call(Pid, Request) ->
    Ref = make_ref(),
    Pid ! {Request, self(), Ref},
    receive
        {reply, Ref, Reply} -> {ok, Reply}
    after 5000 ->
        {error, timeout}
    end.

%% Fire and forget. The send always succeeds, even if the process is dead.
cast(Pid, Message) ->
    Pid ! Message,
    ok.

%% receive scans the mailbox for the first message that matches any clause,
%% which is why this works no matter what order the two messages arrive in.
ping_pong() ->
    Pong = spawn(?MODULE, pong, []),
    Pong ! {ping, self()},
    receive
        pong -> {ok, Pong}
    after 1000 ->
        {error, no_reply}
    end.

pong() ->
    receive
        {ping, From} ->
            From ! pong,
            pong();
        stop ->
            ok
    end.

%% A hundred thousand processes on a laptop, in well under a second.
spawn_many(N) ->
    Parent = self(),
    Started = erlang:monotonic_time(millisecond),
    [spawn(fun() -> Parent ! {done, self()} end) || _ <- lists:seq(1, N)],
    collect(N),
    erlang:monotonic_time(millisecond) - Started.

collect(0) -> ok;
collect(N) ->
    receive
        {done, _Pid} -> collect(N - 1)
    end.
Processes at the prompt
1> self().
<0.85.0>
2> Pid = spawn(fun() -> receive Msg -> io:format("got ~p~n", [Msg]) end end).
<0.88.0>
3> Pid ! hello.
got hello
hello
4> is_process_alive(Pid).
false
5> Pid ! anything.
anything
6> Counter = counter:start().
<0.92.0>
7> counter:add(Counter, 10).
ok
8> counter:value(Counter).
10
9> length(processes()).
58
10> erlang:process_info(Counter, [message_queue_len, memory]).
[{message_queue_len,0},{memory,2688}]
11> exit(Counter, kill).
true
12> is_process_alive(Counter).
false
Python 3.12
import threading


class Counter:
    """Shared mutable state, which is the thing Erlang does not have."""

    def __init__(self) -> None:
        self.count = 0
        # Nothing in the language ties this lock to the field it protects.
        # Remembering to take it is a convention enforced by code review.
        self._lock = threading.Lock()

    def add(self, amount: int) -> None:
        with self._lock:
            self.count += amount

    def value(self) -> int:
        with self._lock:
            return self.count


def main() -> None:
    counter = Counter()
    workers = [
        threading.Thread(target=lambda: [counter.add(1) for _ in range(100)])
        for _ in range(100)
    ]
    for worker in workers:
        worker.start()
    for worker in workers:
        worker.join()

    # Threads are OS threads: about 8 MB of stack each by default, so a
    # hundred thousand of them is not an option. CPU bound work also shares
    # one interpreter lock, so the answer there is multiprocessing.
    print(counter.value())


if __name__ == "__main__":
    main()
JavaScript (Node 22)
// One thread, so there is no lock to forget. The cost is that nothing runs
// while anything else is running: a slow loop in one request handler blocks
// every other request, every timer, and the health check.
class Counter {
  #count = 0;

  add(amount) {
    this.#count += amount;
  }

  get value() {
    return this.#count;
  }
}

const counter = new Counter();
for (let i = 0; i < 10000; i++) counter.add(1);
console.log(counter.value);

// Real parallelism needs worker_threads, and workers cannot share this
// object. State is copied through messages, or squeezed into a
// SharedArrayBuffer with manual Atomics, which is the lock problem again
// with fewer tools.
export { Counter };

Python needs a lock, and nothing in the language ties the lock to the field it protects. Node has one thread, so there is no lock to forget and also no parallelism.

Three rules that explain the rest

RuleConsequence
Sending never blocks and never failsPid ! Message returns immediately, even if the process is dead, overloaded or on another continent. There is no delivery receipt, so if you need one, ask for a reply.
Messages are copiedThe receiver gets its own copy, so neither side can change what the other holds. Large binaries are the exception and are shared by reference.
Order is preserved between two processesMessages from A to B arrive in the order A sent them. Nothing is promised about messages from different senders, which is the same guarantee TCP gives you and no more.

Selective receive

receive does not take the first message in the mailbox. It scans for the first message that matches any of its clauses and leaves the rest where they were. That is what makes the request and reply pattern work: waiting for a reply tagged with a specific reference will not accidentally consume some unrelated message that arrived first.

It is also the one performance trap. If a mailbox has ten thousand messages that match nothing, every receive scans all ten thousand before giving up. Always have a clause that matches anything, or an after timeout, so a mailbox cannot fill up with mail nobody will ever read.

Try it yourself

Write call/2: send a request, wait for the reply, and return {error, timeout} if nothing arrives within five seconds. Make sure a late reply to an earlier call cannot be mistaken for this one's.

Hint: make_ref/0 gives you a value that is unique across the whole cluster. Put it in the request and match on it in the reply.

Show one solution
processes.erl
-module(processes).
-export([start/0, loop/1, call/2, cast/2, ping_pong/0, pong/0, spawn_many/1]).

%% spawn returns a process identifier immediately. The new process runs
%% ?MODULE:loop(0) and has its own heap, its own stack and its own mailbox.
%% It costs about 300 words to create, so making a million of them is a
%% design decision rather than a stunt.
start() ->
    spawn(?MODULE, loop, [0]).

%% This is the entire pattern for stateful code in Erlang: a function that
%% waits for a message, works out the new state, and calls itself with it.
%% The state is an argument, so nothing else can reach in and change it.
loop(Count) ->
    receive
        {increment, N} ->
            loop(Count + N);
        {value, From, Ref} ->
            From ! {reply, Ref, Count},
            loop(Count);
        stop ->
            ok
    after 300000 ->
        %% An idle timeout. Without one, a forgotten process waits forever.
        io:format("counter idle for five minutes, stopping~n"),
        ok
    end.

%% Asking for an answer means sending a message and waiting for the reply.
%% The unique reference is what stops an old, late reply from being mistaken
%% for this one, and the timeout is what stops the caller hanging forever.
%% Getting all three details right every time is why gen_server exists.
call(Pid, Request) ->
    Ref = make_ref(),
    Pid ! {Request, self(), Ref},
    receive
        {reply, Ref, Reply} -> {ok, Reply}
    after 5000 ->
        {error, timeout}
    end.

%% Fire and forget. The send always succeeds, even if the process is dead.
cast(Pid, Message) ->
    Pid ! Message,
    ok.

%% receive scans the mailbox for the first message that matches any clause,
%% which is why this works no matter what order the two messages arrive in.
ping_pong() ->
    Pong = spawn(?MODULE, pong, []),
    Pong ! {ping, self()},
    receive
        pong -> {ok, Pong}
    after 1000 ->
        {error, no_reply}
    end.

pong() ->
    receive
        {ping, From} ->
            From ! pong,
            pong();
        stop ->
            ok
    end.

%% A hundred thousand processes on a laptop, in well under a second.
spawn_many(N) ->
    Parent = self(),
    Started = erlang:monotonic_time(millisecond),
    [spawn(fun() -> Parent ! {done, self()} end) || _ <- lists:seq(1, N)],
    collect(N),
    erlang:monotonic_time(millisecond) - Started.

collect(0) -> ok;
collect(N) ->
    receive
        {done, _Pid} -> collect(N - 1)
    end.
handle_call(Request, From, State) -> {reply, Reply, State}.

gen_server, the behaviour you will use most

A behaviour is an interface plus a battle tested implementation of everything around it. gen_server is the one that covers most stateful processes.

The hand written counter from the last chapter works. What it does not do is time out a caller, survive a code upgrade, respond to system messages, shut down in an orderly way, produce a useful crash report, or let a supervisor manage it. Adding all that is the same work every time, so OTP did it once.

A behaviour splits a process into two halves. The generic half, in OTP, runs the receive loop and handles the protocol. Your half is a set of callbacks that say what this particular server does with a request and how its state changes.

counter_server.erl
%% The same counter as the hand written process, as a gen_server. The extra
%% lines buy timeouts, synchronous calls, code upgrade, system messages,
%% orderly shutdown and the ability for a supervisor to manage it.
-module(counter_server).
-behaviour(gen_server).

%% The public interface. Callers never send messages by hand, so the
%% protocol stays an implementation detail of this module.
-export([start_link/0, increment/0, increment/1, value/0, reset/0, stop/0]).

%% The callbacks gen_server calls. These are never called by your own code.
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2,
         code_change/3]).

-define(SERVER, ?MODULE).

%% ---------------------------------------------------------------- API

start_link() ->
    gen_server:start_link({local, ?SERVER}, ?MODULE, 0, []).

increment() -> increment(1).

%% cast is asynchronous. It returns as soon as the message is on its way.
increment(N) when is_integer(N) ->
    gen_server:cast(?SERVER, {increment, N}).

%% call is synchronous and waits for a reply, with a five second timeout by
%% default. If the server is dead, the caller crashes too, which is usually
%% the behaviour you want.
value() ->
    gen_server:call(?SERVER, value).

reset() ->
    gen_server:call(?SERVER, reset).

stop() ->
    gen_server:stop(?SERVER).

%% ---------------------------------------------------------- callbacks

init(Initial) ->
    %% Whatever this returns becomes the state passed to every callback.
    {ok, Initial}.

handle_call(value, _From, Count) ->
    {reply, Count, Count};
handle_call(reset, _From, _Count) ->
    {reply, ok, 0};
handle_call(Request, _From, Count) ->
    %% Answer rather than crash, so one bad caller cannot take the server
    %% down and with it everyone else's state.
    {reply, {error, {unknown_request, Request}}, Count}.

handle_cast({increment, N}, Count) ->
    {noreply, Count + N};
handle_cast(_Ignored, Count) ->
    {noreply, Count}.

%% Anything that arrives without going through call or cast lands here:
%% monitor DOWN messages, timeouts, and mail from processes that guessed.
handle_info(Info, Count) ->
    logger:info("counter_server ignored ~p", [Info]),
    {noreply, Count}.

terminate(_Reason, _Count) ->
    ok.

%% Called during a hot upgrade, with the old state. Return the new shape.
code_change(_OldVsn, Count, _Extra) ->
    {ok, Count}.

call, cast and info

gen_server:call/2gen_server:cast/2a plain message
Callbackhandle_call/3handle_cast/2handle_info/2
Caller waitsyes, five seconds by defaultnono
Gets a resultyesnono
If the server is deadthe caller crashes toosilently does nothingsilently does nothing
Back pressureyes, the caller is blockednone, the mailbox growsnone
Use forreads, and writes that must be confirmedfire and forget writesmonitors, timeouts, surprises

The instinct from other languages is to make everything asynchronous because it sounds faster. In practice call should be the default. It gives you back pressure for free: if the server cannot keep up, callers wait, rather than filling a mailbox until the machine dies. Reach for cast when the caller genuinely does not need to know.

The callbacks, and what they return

  1. init/1

    Runs inside the new process, before start_link returns. Return {ok, State}, or {stop, Reason} to refuse to start. Keep it fast: a supervisor starts its children one at a time, so slow initialisation delays the whole boot. If setup takes a while, return quickly and send yourself a message to continue, or use {ok, State, {continue, Term}} and handle_continue/2.

  2. handle_call/3

    Return {reply, Reply, NewState}. Or {noreply, NewState} and reply later with gen_server:reply(From, Reply), which is how you answer a caller only once some other work finishes without blocking the server meanwhile.

  3. handle_cast/2 and handle_info/2

    Both return {noreply, NewState} or {stop, Reason, NewState}. Always write a catch all clause for handle_info/2: unexpected messages do arrive, and crashing on them is rarely what you want.

  4. terminate/2 and code_change/3

    terminate/2 runs on an orderly shutdown, and not if the process is killed, so it is for tidiness rather than for anything that must happen. code_change/3 converts old state to new during a hot upgrade.

{ok, {SupFlags, [ChildSpec]}}

Supervision trees

A supervisor starts children, watches them, and restarts them from a known good state. Structuring a system as a tree of these is what OTP is really for.

A supervisor is a process that does nothing but start other processes and react when they die. It contains no business logic at all, and that is deliberate: the part of the system responsible for recovery should be too boring to have bugs in it.

chat_sup.erl
%% The supervisor. It knows how to start its children and what to do when
%% one of them dies. It contains no business logic at all, which is the
%% point: the part that restarts things is too important to be interesting.
-module(chat_sup).
-behaviour(supervisor).

-export([start_link/0, start_room/1, stop_room/1]).
-export([init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
    SupFlags = #{
        %% one_for_one: restart only the child that died.
        %% one_for_all: restart every child, for state that must agree.
        %% rest_for_one: restart it and everything started after it.
        strategy => one_for_one,

        %% More than five restarts in ten seconds means restarting is not
        %% working, so this supervisor gives up and dies too. The failure
        %% then travels up the tree to someone who can do something about it.
        intensity => 5,
        period => 10
    },
    {ok, {SupFlags, [room_spec(lobby)]}}.

%% Rooms can also be added while the system runs.
start_room(Name) ->
    supervisor:start_child(?MODULE, room_spec(Name)).

stop_room(Name) ->
    ok = supervisor:terminate_child(?MODULE, Name),
    supervisor:delete_child(?MODULE, Name).

room_spec(Name) ->
    #{
        id => Name,
        start => {chat_room, start_link, [Name]},
        %% permanent: always restart. transient: only if it died abnormally.
        %% temporary: never restart.
        restart => permanent,
        shutdown => 5000,
        type => worker,
        modules => [chat_room]
    }.
Killing a child and watching it come back
1> chat_sup:start_link().
{ok,<0.95.0>}
2> whereis(lobby).
<0.96.0>
3> chat_room:join(lobby).
ok
4> chat_room:members(lobby).
[<0.85.0>]
5> exit(whereis(lobby), kill).
true
6> whereis(lobby).
<0.99.0>
7> chat_room:members(lobby).
[]
8> supervisor:count_children(chat_sup).
[{specs,1},{active,1},{supervisors,0},{workers,1}]
9> chat_sup:start_room(erlang_questions).
{ok,<0.102.0>}
10> supervisor:which_children(chat_sup).
[{erlang_questions,<0.102.0>,worker,[chat_room]},
 {lobby,<0.99.0>,worker,[chat_room]}]

Restart strategies

one_for_one

Restart just that child

The default, and right when children are independent of each other. One chat room crashing has nothing to do with the others, so only it comes back.

one_for_all

Restart all of them

For children whose state has to agree with each other. If a connection process and the cache built from it get out of step, restarting both together is the only way back to a consistent state.

rest_for_one

Restart it and everything after it

For a chain of dependencies, where later children were started against something the earlier one owns. If the socket dies, whatever was reading from it has to go too.

simple_one_for_one

Many identical children

Now spelled as a simple_one_for_one strategy or, in modern code, a DynamicSupervisor shaped tree: one child specification, started on demand, thousands of times. This is what a connection acceptor uses.

Restart intensity is a circuit breaker

intensity => 5, period => 10 means: if this supervisor has to restart children more than five times in ten seconds, then restarting is not fixing anything, so it gives up and dies too. Its own supervisor then decides what to do, and so on up the tree.

That escalation is the part people miss. A supervision tree is not just a restart loop, it is a way for a local failure to become a larger one only when it needs to. A bad database password does not cause an infinite restart storm in a corner of the system; it takes out the subsystem, then the application, and the node exits with a crash report saying exactly why.

Python 3.12
import time


def run_worker() -> None:
    raise RuntimeError("connection lost")


def supervise(restarts: int = 5, window: float = 10.0) -> None:
    """A supervisor, by hand, badly."""
    attempts: list[float] = []

    while True:
        try:
            run_worker()
        except Exception as problem:  # noqa: BLE001
            now = time.monotonic()
            attempts = [t for t in attempts if now - t < window]
            attempts.append(now)

            # Give up if restarting is clearly not helping. Every process
            # supervisor in every language reinvents this loop, usually
            # without the backoff, usually without the give up.
            if len(attempts) > restarts:
                raise RuntimeError("restarting is not working") from problem

            print(f"restarting after {problem}")
            time.sleep(0.5)


# What this cannot do: restart one worker out of a group, restart the group
# together when their state has to agree, shut children down in the right
# order, or tell the layer above that the whole subsystem is unhealthy.
JavaScript (Node 22)
// The same idea in Node, and the same gaps. Most projects push this out to
// systemd, Docker restart policies or Kubernetes, which restart the entire
// process: every connection dropped, every cache cold, for one bad worker.
export async function supervise(work, { restarts = 5, windowMs = 10_000 } = {}) {
  let attempts = [];

  for (;;) {
    try {
      await work();
      return;
    } catch (problem) {
      const now = Date.now();
      attempts = attempts.filter((t) => now - t < windowMs);
      attempts.push(now);

      if (attempts.length > restarts) {
        throw new Error("restarting is not working", { cause: problem });
      }

      console.warn(`restarting after ${problem.message}`);
      await new Promise((resolve) => setTimeout(resolve, 500));
    }
  }
}

// The granularity is the whole process. Erlang's granularity is one worker
// out of a hundred thousand, and restarting it costs microseconds.

Both reimplement a fragment of this, usually without the escalation, and usually at the granularity of the whole process rather than one worker out of thousands.

Child specifications

KeyMeaning
idhow this supervisor refers to the child. Any term
start{Module, Function, Args}, which must link, so start_link
restartpermanent, transient (only on abnormal exit), or temporary (never)
shutdownmilliseconds to wait for an orderly stop, or brutal_kill, or infinity for a supervisor
typeworker or supervisor
modulesthe modules holding state, used during hot upgrades
application:ensure_all_started(chat).

Applications and releases

An application is the unit OTP starts, stops and upgrades. A release is that plus the runtime, packaged into something a server can boot with no Erlang installed.

An OTP application is a supervision tree plus a description of it. The description lives in a .app.src file and says which other applications must be running first, which module starts the tree, and what configuration it accepts.

chat_app.erl
%% The application callback module. An application is the unit OTP starts,
%% stops and upgrades, and it is what turns a folder of modules into
%% something a release can boot.
-module(chat_app).
-behaviour(application).

-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
    %% Returning the top supervisor's pid is what links the application to
    %% its tree. When the tree dies, the application is reported as stopped.
    chat_sup:start_link().

stop(_State) ->
    ok.
src/chat.app.src
{application, chat, [
    {description, "A chat server with one room per process"},
    {vsn, "0.1.0"},

    %% The module that starts the supervision tree.
    {mod, {chat_app, []}},

    %% Applications that must be running before this one starts. OTP starts
    %% them in order and refuses to boot if one of them fails.
    {applications, [kernel, stdlib, sasl]},

    {registered, [chat_sup]},
    {env, [{max_rooms, 100}, {idle_timeout_ms, 300000}]},
    {modules, []},
    {licenses, ["Apache-2.0"]}
]}.

With those two files, application:ensure_all_started(chat) starts every dependency in order and then this tree. Stopping it stops the tree in reverse order. Nothing else in your code has to know about boot order at all.

The standard project layout

PathHolds
src/your modules and the .app.src file
include/.hrl files, for records shared between modules
test/EUnit and Common Test suites
config/sys.configruntime configuration per environment
config/vm.argsnode name, cookie and virtual machine flags
rebar.configdependencies, profiles, release definition
_build/everything rebar3 produces. Never edit, never commit
apps/for an umbrella project holding several applications
rebar.config
{erl_opts, [debug_info, warnings_as_errors]}.

{deps, [
    {cowboy, "2.12.0"},
    {thoas, "1.2.1"}
]}.

{shell, [{apps, [chat]}]}.

{relx, [
    {release, {chat, "0.1.0"}, [chat, sasl]},
    {mode, dev},
    {sys_config, "./config/sys.config"},
    {vm_args, "./config/vm.args"}
]}.

{profiles, [
    {prod, [{relx, [{mode, prod}, {include_erts, true}]}]},
    {test, [{deps, [{proper, "1.4.0"}]}]}
]}.

{dialyzer, [
    {warnings, [unknown, unmatched_returns, error_handling]},
    {plt_extra_apps, [cowboy]}
]}.

Releases

  1. Describe it

    The relx section of rebar.config lists the applications to include. sasl is worth including: it is what produces readable crash reports and what handles upgrades.

  2. Build it

    rebar3 as prod release produces a directory containing your compiled code, every dependency, and the Erlang runtime itself. Nothing needs to be installed on the target machine.

  3. Run it

    bin/chat foreground under a process supervisor, or bin/chat daemon. In a container, foreground, so the container runtime sees the process it thinks it is running.

  4. Get inside it

    bin/chat remote_console attaches a shell to the running node. This is the part that surprises people coming from other stacks: you can inspect and fix a production system from the inside, live, with the same tools you used while writing it.

ets:new(cache, [named_table, set])

ETS, the shared memory that is allowed

Process state is private, which is usually right and occasionally too slow. ETS is an in memory table any process can read without sending a message.

A gen_server holding a lookup table is a bottleneck: every reader sends a message and waits for a reply, and they all queue behind each other. For data that is read constantly and written rarely, that is the wrong shape.

ETS is a set of tables built into the runtime. Any process can read one directly, in parallel, with no message and no copy on the way in. It is the escape hatch, and it is well behaved as long as you remember what it is not: it has no transactions across tables, and reading and then writing is not atomic.

cache.erl
%% ETS is the shared memory Erlang does allow: a table of tuples that any
%% process can read without sending a message. It is the right tool for data
%% that is read constantly and owned by nobody in particular.
-module(cache).
-export([init/0, store/2, fetch/1, forget/1, count/0, expire_older_than/1]).

-define(TABLE, cache_table).

init() ->
    %% named_table lets other modules use the atom instead of the table id.
    %% public means any process may write, read_concurrency optimises for
    %% many readers. The owning process holds the table: when it dies, the
    %% table disappears, so tables belong to long lived processes.
    ets:new(?TABLE, [named_table, set, public, {read_concurrency, true}]).

store(Key, Value) ->
    true = ets:insert(?TABLE, {Key, Value, seconds_now()}),
    ok.

fetch(Key) ->
    case ets:lookup(?TABLE, Key) of
        [{Key, Value, _Stored}] -> {ok, Value};
        [] -> not_found
    end.

forget(Key) ->
    true = ets:delete(?TABLE, Key),
    ok.

count() ->
    ets:info(?TABLE, size).

%% A match specification. It looks like line noise the first time, and it
%% runs inside the table without copying anything into your process, which
%% is why it is worth the ugliness for large tables.
expire_older_than(Seconds) ->
    Cutoff = seconds_now() - Seconds,
    ets:select_delete(?TABLE, [{{'_', '_', '$1'}, [{'<', '$1', Cutoff}], [true]}]).

seconds_now() ->
    erlang:system_time(second).

Table types

TypeBehaviour
setone row per key. The default and usually what you want
ordered_setone row per key, kept sorted, so range queries work
bagseveral rows per key, no duplicates
duplicate_bagseveral rows per key, duplicates allowed
OptionEffect
named_tablereachable by atom instead of by the returned id
publicany process may read and write. protected: read by all, written by the owner. private: owner only
{read_concurrency, true}optimise for many parallel readers, few writers
{write_concurrency, true}optimise for many parallel writers to different keys
compressedsmaller, slower to read. For large mostly cold tables

The neighbours

ToolFor
persistent_termvalues read constantly and written almost never, such as configuration. Reads are free; every write copies the whole table and triggers a global GC, so treat writes as a deployment event
counters and atomicsfast shared integers, when a counter really is the hot path
detsthe same idea on disk. Largely superseded
mnesiaa distributed database built on ETS, with transactions and replication across nodes. Excellent for configuration and small shared state, and rarely the right answer for large or write heavy data
the process dictionaryput/2 and get/1 inside one process. It is mutable state hiding inside a pure looking function, so it makes code hard to follow. Real uses exist, and they are rare
loop(Secret, Attempt) -> Attempt.

Build: guess the number

A loop with no loop, input that cannot crash on bad text, and a comparison written as a pattern rather than as a condition.

The smallest interesting program that needs randomness, input, a loop and a counter. Copy it into guess.erl, then c(guess). and guess:start(). in the shell.

guess.erl
-module(guess).
-export([start/0]).

start() ->
    Secret = rand:uniform(100),
    io:format("I picked a number between 1 and 100.~n"),
    Attempts = loop(Secret, 1),
    io:format("Solved it in ~p guesses.~n", [Attempts]).

%% There is no while loop. The loop is a function that calls itself, and the
%% guess counter is an argument rather than a variable being reassigned.
loop(Secret, Attempt) ->
    case io:fread("> ", "~d") of
        %% Secret is already bound, so this clause only matches when the
        %% number entered is equal to it. Using a bound variable in a pattern
        %% is the same as writing an equality test, and it reads better.
        {ok, [Secret]} ->
            Attempt;
        {ok, [Guess]} when Guess < Secret ->
            io:format("Higher.~n"),
            loop(Secret, Attempt + 1);
        {ok, [_Higher]} ->
            io:format("Lower.~n"),
            loop(Secret, Attempt + 1);
        eof ->
            io:format("~nBye.~n"),
            Attempt;
        {error, _Reason} ->
            io:format("That is not a number.~n"),
            loop(Secret, Attempt)
    end.
Python 3.12
import random


def main() -> None:
    secret = random.randint(1, 100)
    print("I picked a number between 1 and 100.")
    attempt = 1

    while True:
        entered = input("> ").strip()
        try:
            guess = int(entered)
        except ValueError:
            print("That is not a number.")
            continue

        if guess < secret:
            print("Higher.")
        elif guess > secret:
            print("Lower.")
        else:
            print(f"Solved it in {attempt} guesses.")
            return
        attempt += 1


if __name__ == "__main__":
    main()
JavaScript (Node 22)
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

async function main() {
  const rl = readline.createInterface({ input, output });
  const secret = Math.floor(Math.random() * 100) + 1;
  console.log("I picked a number between 1 and 100.");

  let attempt = 1;
  for (;;) {
    const entered = (await rl.question("> ")).trim();
    const guess = Number(entered);

    if (!Number.isInteger(guess)) {
      console.log("That is not a number.");
      continue;
    }
    if (guess < secret) console.log("Higher.");
    else if (guess > secret) console.log("Lower.");
    else {
      console.log(`Solved it in ${attempt} guesses.`);
      break;
    }
    attempt += 1;
  }

  rl.close();
}

main();

Three things worth noticing

The loop is a function. loop/2 calls itself with Attempt + 1. It is a tail call, so this runs in constant stack space whether the player takes five guesses or five million.

The winning case is a pattern, not a test. {ok, [Secret]} matches only when the number entered equals the already bound Secret. No comparison is written, because matching against a bound variable is a comparison.

Bad input is a value. io:fread/2 returns {error, Reason} rather than raising, so the clause for nonsense sits beside the clause for a real guess instead of in a catch block somewhere else.

Try it yourself

Give the player five guesses and reveal the answer when they run out. Then keep the guesses so far in a list and print them at the end.

Hint: Both changes are extra parameters on loop. Nothing else has to move.

Show one solution
guess.erl
-module(guess).
-export([start/0]).

start() ->
    Secret = rand:uniform(100),
    io:format("I picked a number between 1 and 100.~n"),
    Attempts = loop(Secret, 1),
    io:format("Solved it in ~p guesses.~n", [Attempts]).

%% There is no while loop. The loop is a function that calls itself, and the
%% guess counter is an argument rather than a variable being reassigned.
loop(Secret, Attempt) ->
    case io:fread("> ", "~d") of
        %% Secret is already bound, so this clause only matches when the
        %% number entered is equal to it. Using a bound variable in a pattern
        %% is the same as writing an equality test, and it reads better.
        {ok, [Secret]} ->
            Attempt;
        {ok, [Guess]} when Guess < Secret ->
            io:format("Higher.~n"),
            loop(Secret, Attempt + 1);
        {ok, [_Higher]} ->
            io:format("Lower.~n"),
            loop(Secret, Attempt + 1);
        eof ->
            io:format("~nBye.~n"),
            Attempt;
        {error, _Reason} ->
            io:format("That is not a number.~n"),
            loop(Secret, Attempt)
    end.
tally(true, Score) -> Score#score{correct = C + 1}.

Build: an arithmetic drill

Keeping score without a counter to increment, and what record update actually costs.

The same loop with something extra to carry: a score with two numbers in it. In Python you would reach for a small class and edit its fields. Here the score is a value, and each answer produces a new one.

drill.erl
-module(drill).
-export([start/0]).

%% The running score is a record, so it can be printed, passed around and
%% tested without any chance of another part of the program editing it.
-record(score, {correct = 0, asked = 0}).

start() ->
    io:format("Addition drill. Type quit to stop.~n"),
    #score{correct = Correct, asked = Asked} = loop(#score{}),
    io:format("~p correct out of ~p~n", [Correct, Asked]).

loop(Score) ->
    A = rand:uniform(10),
    B = rand:uniform(10),
    Prompt = io_lib:format("What is ~p + ~p? ", [A, B]),
    case io:get_line(Prompt) of
        eof -> Score;
        Line -> answered(string:trim(Line), A + B, Score)
    end.

%% Two clauses instead of an if. The first pattern is a literal string, so
%% the routing decision is made by the same mechanism as everything else.
answered("quit", _Answer, Score) ->
    Score;
answered(Input, Answer, Score) ->
    IsRight = parse(Input) =:= {ok, Answer},
    case IsRight of
        true -> io:format("Correct.~n");
        false -> io:format("No, it was ~p.~n", [Answer])
    end,
    loop(tally(IsRight, Score)).

%% A new score is returned. The old one still exists, unchanged.
tally(true, Score = #score{correct = C, asked = A}) ->
    Score#score{correct = C + 1, asked = A + 1};
tally(false, Score = #score{asked = A}) ->
    Score#score{asked = A + 1}.

parse(Text) ->
    case string:to_integer(Text) of
        {error, _Reason} -> error;
        {Number, _Rest} -> {ok, Number}
    end.
Python 3.12
import random
from dataclasses import dataclass


@dataclass
class Score:
    correct: int = 0
    asked: int = 0


def main() -> None:
    print("Addition drill. Type quit to stop.")
    score = Score()

    while True:
        a, b = random.randint(1, 10), random.randint(1, 10)
        entered = input(f"What is {a} + {b}? ").strip()
        if entered == "quit":
            break

        answer = a + b
        is_right = entered.lstrip("-").isdigit() and int(entered) == answer
        print("Correct." if is_right else f"No, it was {answer}.")

        # The score object is edited in place, so anything holding a
        # reference to it sees the change.
        score.asked += 1
        score.correct += int(is_right)

    print(f"{score.correct} correct out of {score.asked}")


if __name__ == "__main__":
    main()
JavaScript (Node 22)
import * as readline from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";

async function main() {
  const rl = readline.createInterface({ input, output });
  console.log("Addition drill. Type quit to stop.");

  const score = { correct: 0, asked: 0 };

  for (;;) {
    const a = Math.floor(Math.random() * 10) + 1;
    const b = Math.floor(Math.random() * 10) + 1;
    const entered = (await rl.question(`What is ${a} + ${b}? `)).trim();
    if (entered === "quit") break;

    const answer = a + b;
    const isRight = Number(entered) === answer;
    console.log(isRight ? "Correct." : `No, it was ${answer}.`);

    score.asked += 1;
    score.correct += isRight ? 1 : 0;
  }

  console.log(`${score.correct} correct out of ${score.asked}`);
  rl.close();
}

main();

Both edit the score in place. Anything else holding a reference to it sees the change, which is convenient until it is not.

Record update is a copy, and it is cheap

Score#score{correct = C + 1} builds a new record with one field different. The original still exists and still holds its old value, which is what makes it safe to hand a score to another function without wondering what it might do to it.

The copy is shallow. A record is a tuple, so this allocates a new tuple of the same size and points at the same field values as before. Nothing nested is copied. For a record with four fields that is four words, which is why nobody in Erlang worries about it.

loop(Count) -> receive {add, N} -> loop(Count + N) end.

Build: a counter, twice

The same thing written by hand and then as a gen_server, so the difference between them is a diff rather than a claim.

Python and JavaScript keep a counter in a variable and change it. Erlang has no variable to change, so the counter lives in a process that waits for messages and calls itself with the new value. That sounds like more work and buys something specific: the state is unreachable from outside, so there is nothing to lock and nothing that can be modified behind your back.

counter.erl
%% The counter with no gen_server, so you can see what OTP is doing for you.
-module(counter).
-export([start/0, loop/1, add/2, value/1, stop/1]).

start() ->
    spawn(?MODULE, loop, [0]).

%% The state lives in the argument. There is no variable to reassign, and no
%% lock, because nothing outside this process can reach it.
loop(Count) ->
    receive
        {add, Amount} ->
            loop(Count + Amount);
        {value, From} ->
            From ! {value, Count},
            loop(Count);
        stop ->
            ok
    end.

add(Pid, Amount) ->
    Pid ! {add, Amount},
    ok.

value(Pid) ->
    Pid ! {value, self()},
    receive
        {value, Count} -> Count
    after 1000 ->
        {error, timeout}
    end.

stop(Pid) ->
    Pid ! stop,
    ok.
Python 3.12
import threading


class Counter:
    """Shared mutable state, which is the thing Erlang does not have."""

    def __init__(self) -> None:
        self.count = 0
        # Nothing in the language ties this lock to the field it protects.
        # Remembering to take it is a convention enforced by code review.
        self._lock = threading.Lock()

    def add(self, amount: int) -> None:
        with self._lock:
            self.count += amount

    def value(self) -> int:
        with self._lock:
            return self.count


def main() -> None:
    counter = Counter()
    workers = [
        threading.Thread(target=lambda: [counter.add(1) for _ in range(100)])
        for _ in range(100)
    ]
    for worker in workers:
        worker.start()
    for worker in workers:
        worker.join()

    # Threads are OS threads: about 8 MB of stack each by default, so a
    # hundred thousand of them is not an option. CPU bound work also shares
    # one interpreter lock, so the answer there is multiprocessing.
    print(counter.value())


if __name__ == "__main__":
    main()
JavaScript (Node 22)
// One thread, so there is no lock to forget. The cost is that nothing runs
// while anything else is running: a slow loop in one request handler blocks
// every other request, every timer, and the health check.
class Counter {
  #count = 0;

  add(amount) {
    this.#count += amount;
  }

  get value() {
    return this.#count;
  }
}

const counter = new Counter();
for (let i = 0; i < 10000; i++) counter.add(1);
console.log(counter.value);

// Real parallelism needs worker_threads, and workers cannot share this
// object. State is copied through messages, or squeezed into a
// SharedArrayBuffer with manual Atomics, which is the lock problem again
// with fewer tools.
export { Counter };

Now the OTP version

The hand written version works and is missing everything that makes a process production worthy. Here is the same counter as a gen_server, with the differences visible side by side.

counter_server.erl
%% The same counter as the hand written process, as a gen_server. The extra
%% lines buy timeouts, synchronous calls, code upgrade, system messages,
%% orderly shutdown and the ability for a supervisor to manage it.
-module(counter_server).
-behaviour(gen_server).

%% The public interface. Callers never send messages by hand, so the
%% protocol stays an implementation detail of this module.
-export([start_link/0, increment/0, increment/1, value/0, reset/0, stop/0]).

%% The callbacks gen_server calls. These are never called by your own code.
-export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2,
         code_change/3]).

-define(SERVER, ?MODULE).

%% ---------------------------------------------------------------- API

start_link() ->
    gen_server:start_link({local, ?SERVER}, ?MODULE, 0, []).

increment() -> increment(1).

%% cast is asynchronous. It returns as soon as the message is on its way.
increment(N) when is_integer(N) ->
    gen_server:cast(?SERVER, {increment, N}).

%% call is synchronous and waits for a reply, with a five second timeout by
%% default. If the server is dead, the caller crashes too, which is usually
%% the behaviour you want.
value() ->
    gen_server:call(?SERVER, value).

reset() ->
    gen_server:call(?SERVER, reset).

stop() ->
    gen_server:stop(?SERVER).

%% ---------------------------------------------------------- callbacks

init(Initial) ->
    %% Whatever this returns becomes the state passed to every callback.
    {ok, Initial}.

handle_call(value, _From, Count) ->
    {reply, Count, Count};
handle_call(reset, _From, _Count) ->
    {reply, ok, 0};
handle_call(Request, _From, Count) ->
    %% Answer rather than crash, so one bad caller cannot take the server
    %% down and with it everyone else's state.
    {reply, {error, {unknown_request, Request}}, Count}.

handle_cast({increment, N}, Count) ->
    {noreply, Count + N};
handle_cast(_Ignored, Count) ->
    {noreply, Count}.

%% Anything that arrives without going through call or cast lands here:
%% monitor DOWN messages, timeouts, and mail from processes that guessed.
handle_info(Info, Count) ->
    logger:info("counter_server ignored ~p", [Info]),
    {noreply, Count}.

terminate(_Reason, _Count) ->
    ok.

%% Called during a hot upgrade, with the old state. Return the new shape.
code_change(_OldVsn, Count, _Extra) ->
    {ok, Count}.
By handgen_server
Linesabout 30about 60
Timeouts on a callyou write them, every timebuilt in, five seconds by default
Caller crashes if the server is goneno, it hangsyes, which is what you want
Supervisor can manage itonly if you remember to linkyes, that is what start_link is for
Crash report names the modulenoyes, with the state and the last message
Hot code upgradeyou handle it yourselfcode_change/3
sys:get_state, sys:tracenoyes, on any gen_server, live
build(N, Next) -> spawn(?MODULE, node_loop, [Next]).

Build: the ring benchmark

N processes in a circle passing a token M times. The classic demonstration, and the fastest way to feel how cheap a process is.

Spawn a hundred thousand processes, arrange them in a ring, and send a message all the way round a hundred times. That is ten million messages. On a laptop it takes a few seconds and a few hundred megabytes.

The same program in Python asks the operating system for a hundred thousand threads, which it will refuse to give you. In Node it becomes a hundred thousand promises on one thread, which measures how fast the event loop can shuffle callbacks rather than how fast messages move between isolated units of work.

ring.erl
%% The ring benchmark. N processes in a circle, passing a token round M
%% times. In Python or Node this is a thought experiment; here it is a
%% one line call in the shell.
-module(ring).
-export([start/2, node_loop/1]).

start(N, M) when N > 0, M > 0 ->
    Started = erlang:monotonic_time(millisecond),

    %% Build the ring backwards, so each process is created already knowing
    %% who to forward to. The last one points back at us.
    Head = build(N, self()),

    Head ! {token, M},
    ok = wait(Head),

    %% Tell everyone to stop, and wait for the message to come back around.
    Head ! stop,
    receive stop -> ok end,

    Elapsed = erlang:monotonic_time(millisecond) - Started,
    io:format("~p processes, ~p laps, ~p messages in ~p ms~n",
              [N, M, N * M, Elapsed]),
    Elapsed.

build(0, Next) ->
    Next;
build(N, Next) ->
    build(N - 1, spawn(?MODULE, node_loop, [Next])).

%% Every process in the ring does the same thing: take a message, pass it on.
node_loop(Next) ->
    receive
        stop ->
            Next ! stop,
            ok;
        Message ->
            Next ! Message,
            node_loop(Next)
    end.

wait(Head) ->
    receive
        {token, 1} ->
            ok;
        {token, Laps} ->
            Head ! {token, Laps - 1},
            wait(Head)
    end.
Python 3.12
import queue
import threading


def ring(size: int, laps: int) -> None:
    """The Erlang ring benchmark, as far as Python can take it."""
    queues = [queue.Queue() for _ in range(size)]

    def node(index: int) -> None:
        inbox = queues[index]
        outbox = queues[(index + 1) % size]
        while True:
            message = inbox.get()
            if message is None:
                outbox.put(None)
                return
            outbox.put(message)

    threads = [threading.Thread(target=node, args=(i,), daemon=True) for i in range(size)]
    for thread in threads:
        thread.start()

    for _ in range(laps):
        queues[0].put("token")
    queues[0].put(None)


# size = 100000 asks the operating system for a hundred thousand threads.
# It will refuse, and if it did not, the memory would not be there. This is
# the point at which the two models stop being comparable.
if __name__ == "__main__":
    ring(1000, 10)
JavaScript (Node 22)
// The ring, with promises standing in for processes. They are not processes:
// everything below runs on one thread, so this measures how fast the event
// loop can shuffle callbacks rather than how fast messages move.
export async function ring(size, laps) {
  const inboxes = Array.from({ length: size }, () => []);
  const waiting = Array.from({ length: size }, () => null);

  const send = (index, message) => {
    const resolve = waiting[index];
    if (resolve) {
      waiting[index] = null;
      resolve(message);
    } else {
      inboxes[index].push(message);
    }
  };

  const receive = (index) =>
    inboxes[index].length > 0
      ? Promise.resolve(inboxes[index].shift())
      : new Promise((resolve) => (waiting[index] = resolve));

  const node = async (index) => {
    for (;;) {
      const message = await receive(index);
      if (message === null) return send((index + 1) % size, null);
      send((index + 1) % size, message);
    }
  };

  const nodes = Array.from({ length: size }, (_, i) => node(i));
  for (let lap = 0; lap < laps; lap++) send(0, "token");
  send(0, null);
  await Promise.all(nodes);
}

// There is also no isolation here. One of these throwing takes out the
// whole ring, and there is nobody watching to start it again.
ring(1000, 10);

Both are written to be as fair as the platform allows, and both stop scaling three orders of magnitude before Erlang does.

What the numbers look like

ProcessesLapsMessagesRoughly
1 000100100 000under 100 ms
100 000101 000 000about a second
1 000 00011 000 000a few seconds, mostly spawning

From a 2023 laptop. Yours will differ, and the shape of the curve will not: it is close to linear in the number of messages, and spawning is close to free.

handle_info({'DOWN', Ref, process, Pid, _}, State)

Build: a chat server with one room per process

The whole of OTP in one small program: a gen_server per room, a supervisor above them, monitors so member lists cannot fill with ghosts.

A chat room holds a list of members and forwards messages to them. In most languages that is a shared dictionary and a lock. Here it is a process, and the design falls out of that: one room per process, so a bug in one room cannot corrupt another, and a crash loses one room's member list rather than everyone's.

chat_room.erl
%% One chat room, holding its members in its own state. Nothing is shared
%% with any other room, so a bug in one cannot corrupt another.
-module(chat_room).
-behaviour(gen_server).

-export([start_link/1, join/1, leave/1, say/2, members/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).

-record(state, {name, members = #{}}).

%% ---------------------------------------------------------------- API

start_link(Name) when is_atom(Name) ->
    gen_server:start_link({local, Name}, ?MODULE, Name, []).

join(Room) -> gen_server:call(Room, {join, self()}).

leave(Room) -> gen_server:cast(Room, {leave, self()}).

say(Room, Text) -> gen_server:cast(Room, {say, self(), Text}).

members(Room) -> gen_server:call(Room, members).

%% ---------------------------------------------------------- callbacks

init(Name) ->
    {ok, #state{name = Name}}.

handle_call({join, Pid}, _From, State = #state{members = Members}) ->
    %% Monitor the joiner. When their process dies for any reason, this room
    %% is told, so a member list can never fill up with ghosts.
    Ref = erlang:monitor(process, Pid),
    {reply, ok, State#state{members = Members#{Pid => Ref}}};
handle_call(members, _From, State) ->
    {reply, maps:keys(State#state.members), State};
handle_call(Request, _From, State) ->
    {reply, {error, {unknown_request, Request}}, State}.

handle_cast({say, From, Text}, State = #state{name = Name, members = Members}) ->
    Message = {chat, Name, From, Text},
    lists:foreach(
        fun(Pid) when Pid =/= From -> Pid ! Message;
           (_Self) -> ok
        end,
        maps:keys(Members)
    ),
    {noreply, State};
handle_cast({leave, Pid}, State) ->
    {noreply, forget(Pid, State)};
handle_cast(_Ignored, State) ->
    {noreply, State}.

handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
    {noreply, forget(Pid, State)};
handle_info(_Ignored, State) ->
    {noreply, State}.

%% ------------------------------------------------------------ internal

forget(Pid, State = #state{members = Members}) ->
    case maps:take(Pid, Members) of
        {Ref, Rest} ->
            erlang:demonitor(Ref, [flush]),
            State#state{members = Rest};
        error ->
            State
    end.

Monitors keep the member list honest

When somebody joins, the room monitors their process. If they disconnect, crash, or their machine catches fire, the room receives a DOWN message and removes them. Nothing has to remember to call leave, and there is no periodic sweep looking for stale entries.

This is the pattern that replaces most cleanup code in Erlang. Instead of tracking whether a resource is still needed, monitor the process that needs it and react when it goes away.

chat_sup.erl
%% The supervisor. It knows how to start its children and what to do when
%% one of them dies. It contains no business logic at all, which is the
%% point: the part that restarts things is too important to be interesting.
-module(chat_sup).
-behaviour(supervisor).

-export([start_link/0, start_room/1, stop_room/1]).
-export([init/1]).

start_link() ->
    supervisor:start_link({local, ?MODULE}, ?MODULE, []).

init([]) ->
    SupFlags = #{
        %% one_for_one: restart only the child that died.
        %% one_for_all: restart every child, for state that must agree.
        %% rest_for_one: restart it and everything started after it.
        strategy => one_for_one,

        %% More than five restarts in ten seconds means restarting is not
        %% working, so this supervisor gives up and dies too. The failure
        %% then travels up the tree to someone who can do something about it.
        intensity => 5,
        period => 10
    },
    {ok, {SupFlags, [room_spec(lobby)]}}.

%% Rooms can also be added while the system runs.
start_room(Name) ->
    supervisor:start_child(?MODULE, room_spec(Name)).

stop_room(Name) ->
    ok = supervisor:terminate_child(?MODULE, Name),
    supervisor:delete_child(?MODULE, Name).

room_spec(Name) ->
    #{
        id => Name,
        start => {chat_room, start_link, [Name]},
        %% permanent: always restart. transient: only if it died abnormally.
        %% temporary: never restart.
        restart => permanent,
        shutdown => 5000,
        type => worker,
        modules => [chat_room]
    }.
Killing a room and watching it return
1> chat_sup:start_link().
{ok,<0.95.0>}
2> whereis(lobby).
<0.96.0>
3> chat_room:join(lobby).
ok
4> chat_room:members(lobby).
[<0.85.0>]
5> exit(whereis(lobby), kill).
true
6> whereis(lobby).
<0.99.0>
7> chat_room:members(lobby).
[]
8> supervisor:count_children(chat_sup).
[{specs,1},{active,1},{supervisors,0},{workers,1}]
9> chat_sup:start_room(erlang_questions).
{ok,<0.102.0>}
10> supervisor:which_children(chat_sup).
[{erlang_questions,<0.102.0>,worker,[chat_room]},
 {lobby,<0.99.0>,worker,[chat_room]}]

What the supervisor does and does not save

  1. A room crashes

    Its heap goes away, including its member list. The other rooms are untouched, because they never shared anything with it.

  2. The supervisor restarts it

    A new process with the same registered name, starting from init/1, which means an empty member list. The clients' monitors on the old room fire, so they know to rejoin.

  3. State is not restored

    This is the part worth being honest about. Supervision gives you a clean process, not the state it had. If state must survive a crash it has to live somewhere else: in ETS owned by a longer lived process, in a database, or in the clients, who can be asked to rejoin.

Try it yourself

Add message history: keep the last twenty messages in the room's state and send them to anyone who joins. Then decide what should happen to that history when the room crashes, and implement whichever answer you argued for.

Hint: A bounded list, built with [New | lists:sublist(Old, 19)]. For surviving a crash, an ETS table owned by the supervisor is the smallest change that works.

Show one solution
chat_room.erl
%% One chat room, holding its members in its own state. Nothing is shared
%% with any other room, so a bug in one cannot corrupt another.
-module(chat_room).
-behaviour(gen_server).

-export([start_link/1, join/1, leave/1, say/2, members/1]).
-export([init/1, handle_call/3, handle_cast/2, handle_info/2]).

-record(state, {name, members = #{}}).

%% ---------------------------------------------------------------- API

start_link(Name) when is_atom(Name) ->
    gen_server:start_link({local, Name}, ?MODULE, Name, []).

join(Room) -> gen_server:call(Room, {join, self()}).

leave(Room) -> gen_server:cast(Room, {leave, self()}).

say(Room, Text) -> gen_server:cast(Room, {say, self(), Text}).

members(Room) -> gen_server:call(Room, members).

%% ---------------------------------------------------------- callbacks

init(Name) ->
    {ok, #state{name = Name}}.

handle_call({join, Pid}, _From, State = #state{members = Members}) ->
    %% Monitor the joiner. When their process dies for any reason, this room
    %% is told, so a member list can never fill up with ghosts.
    Ref = erlang:monitor(process, Pid),
    {reply, ok, State#state{members = Members#{Pid => Ref}}};
handle_call(members, _From, State) ->
    {reply, maps:keys(State#state.members), State};
handle_call(Request, _From, State) ->
    {reply, {error, {unknown_request, Request}}, State}.

handle_cast({say, From, Text}, State = #state{name = Name, members = Members}) ->
    Message = {chat, Name, From, Text},
    lists:foreach(
        fun(Pid) when Pid =/= From -> Pid ! Message;
           (_Self) -> ok
        end,
        maps:keys(Members)
    ),
    {noreply, State};
handle_cast({leave, Pid}, State) ->
    {noreply, forget(Pid, State)};
handle_cast(_Ignored, State) ->
    {noreply, State}.

handle_info({'DOWN', _Ref, process, Pid, _Reason}, State) ->
    {noreply, forget(Pid, State)};
handle_info(_Ignored, State) ->
    {noreply, State}.

%% ------------------------------------------------------------ internal

forget(Pid, State = #state{members = Members}) ->
    case maps:take(Pid, Members) of
        {Ref, Rest} ->
            erlang:demonitor(Ref, [flush]),
            State#state{members = Rest};
        error ->
            State
    end.
tally(Contents) -> #{binary() => integer()}.

Build: counting words in a file

Reading a file, counting with a map, sorting by frequency, and keeping every interesting function free of side effects so the tests need no fixtures.

A small program with the shape most Erlang programs have: a thin layer that talks to the world, and pure functions underneath doing the actual work.

word_count.erl
-module(word_count).
-export([main/1, top/2, tally/1, tokens/1]).

%% The only function that touches the outside world.
main([Path]) ->
    case file:read_file(Path) of
        {ok, Contents} ->
            lists:foreach(fun report/1, top(Contents, 10));
        {error, Reason} ->
            io:format("could not read ~s: ~p~n", [Path, Reason])
    end;
main(_Args) ->
    io:format("usage: word_count FILE~n").

report({Word, Count}) ->
    io:format("~-16s~p~n", [Word, Count]).

%% Everything below is pure. It can be tried in the shell on a literal, and
%% the tests need no file, no fixture and no temporary directory.
tokens(Contents) ->
    Lowered = string:lowercase(Contents),
    string:lexemes(Lowered, " \r\n\t.,;:!?\"'()[]{}<>/-").

%% update_with takes the function to apply when the key is already there and
%% the value to use when it is not, which is counting in one call.
tally(Contents) ->
    lists:foldl(
        fun(Word, Counts) ->
            maps:update_with(Word, fun(N) -> N + 1 end, 1, Counts)
        end,
        #{},
        tokens(Contents)
    ).

top(Contents, Limit) ->
    Ranked = lists:sort(
        fun({WordA, CountA}, {WordB, CountB}) ->
            {CountB, WordA} =< {CountA, WordB}
        end,
        maps:to_list(tally(Contents))
    ),
    lists:sublist(Ranked, Limit).
Python 3.12
import sys
from collections import Counter


def tokens(text: str) -> list[str]:
    return "".join(c.lower() if c.isalpha() else " " for c in text).split()


def tally(text: str) -> Counter[str]:
    return Counter(tokens(text))


def main() -> None:
    if len(sys.argv) != 2:
        print("usage: wordcount FILE")
        return

    with open(sys.argv[1], encoding="utf-8") as handle:
        contents = handle.read()

    for word, count in tally(contents).most_common(10):
        print(f"{word:<16}{count}")


if __name__ == "__main__":
    main()
JavaScript (Node 22)
import { readFile } from "node:fs/promises";
import { argv } from "node:process";

export const tokens = (text) =>
  [...text.toLowerCase()]
    .map((c) => (/\p{L}/u.test(c) ? c : " "))
    .join("")
    .split(/\s+/)
    .filter(Boolean);

export function tally(text) {
  const counts = new Map();
  for (const word of tokens(text)) {
    counts.set(word, (counts.get(word) ?? 0) + 1);
  }
  return counts;
}

async function main() {
  const path = argv[2];
  if (!path) {
    console.log("usage: wordcount FILE");
    return;
  }

  const contents = await readFile(path, "utf8");
  const ranked = [...tally(contents)].sort((a, b) => b[1] - a[1]).slice(0, 10);

  for (const [word, count] of ranked) {
    console.log(word.padEnd(16) + count);
  }
}

main();

Python's Counter does more of the work. The Erlang version spells the fold out, which is a fair trade for being able to try tally/1 on a literal in the shell.

Binaries all the way through

file:read_file/1 returns a binary, and string:lexemes/2 and string:lowercase/1 both work on binaries and return them. Nothing is converted to a list of characters at any point, which is what keeps this usable on a file of any size.

The map keys are binaries too, and binaries above 64 bytes are shared rather than copied when sent between processes, so handing these counts to another process later stays cheap.

Try it yourself

Add a stop word list so "the" and "and" are left out, and make the number of results a second command line argument. Then count a large file by splitting it into chunks, counting each in its own process, and merging the maps.

Hint: For the parallel version, spawn one process per chunk and have each send its map back. maps:merge_with/3 combines two count maps correctly.

Show one solution
word_count.erl
-module(word_count).
-export([main/1, top/2, tally/1, tokens/1]).

%% The only function that touches the outside world.
main([Path]) ->
    case file:read_file(Path) of
        {ok, Contents} ->
            lists:foreach(fun report/1, top(Contents, 10));
        {error, Reason} ->
            io:format("could not read ~s: ~p~n", [Path, Reason])
    end;
main(_Args) ->
    io:format("usage: word_count FILE~n").

report({Word, Count}) ->
    io:format("~-16s~p~n", [Word, Count]).

%% Everything below is pure. It can be tried in the shell on a literal, and
%% the tests need no file, no fixture and no temporary directory.
tokens(Contents) ->
    Lowered = string:lowercase(Contents),
    string:lexemes(Lowered, " \r\n\t.,;:!?\"'()[]{}<>/-").

%% update_with takes the function to apply when the key is already there and
%% the value to use when it is not, which is counting in one call.
tally(Contents) ->
    lists:foldl(
        fun(Word, Counts) ->
            maps:update_with(Word, fun(N) -> N + 1 end, 1, Counts)
        end,
        #{},
        tokens(Contents)
    ).

top(Contents, Limit) ->
    Ranked = lists:sort(
        fun({WordA, CountA}, {WordB, CountB}) ->
            {CountB, WordA} =< {CountA, WordB}
        end,
        maps:to_list(tally(Contents))
    ),
    lists:sublist(Ranked, Limit).
spawn(Node, fun() -> work() end)

Distributed Erlang

Sending a message to a process on another machine uses exactly the same syntax as sending to one next door. That is the feature, and it is also the trap.

Give two Erlang nodes the same cookie and connect them, and a pid from one is usable from the other. Pid ! Message works across the network with no client library, no serialisation format to pick and no interface definition to write.

Connections are transitive by default: connect to one node in a cluster and you are connected to all of them, because each node tells the others about the new arrival.

Starting two nodes
# Two nodes on one machine. -sname is short names on a local network,
# -name is fully qualified names across networks.
erl -sname alice -setcookie chocolate
erl -sname bob -setcookie chocolate

# In alice's shell:
#   net_adm:ping('bob@thishost').        %% pong
#   nodes().                             %% ['bob@thishost']
#   rpc:call('bob@thishost', erlang, node, []).
#   spawn('bob@thishost', fun() -> io:format("hi from ~p~n", [node()]) end).

# The cookie is the entire authentication mechanism, so a distributed
# cluster belongs on a private network or behind TLS distribution.
erl -name alice@10.0.0.1 -setcookie chocolate \
    -proto_dist inet_tls \
    -ssl_dist_optfile /etc/erlang/tls.config
dist.erl
%% Distributed Erlang. A cluster is a set of nodes that share a cookie and
%% can send each other messages using exactly the same syntax as a local
%% send. There is no client library and no serialisation code to write.
-module(dist).
-export([connect/1, cluster/0, remote_time/1, spawn_there/1, tell/3,
         watch_nodes/0, register_globally/2, find_globally/1]).

%% net_adm:ping is how a node joins a cluster: connect to one member and the
%% rest are discovered automatically, because connections are transitive by
%% default.
connect(Node) ->
    case net_adm:ping(Node) of
        pong -> {connected, Node};
        pang -> {unreachable, Node}
    end.

cluster() ->
    [node() | nodes()].

%% Calling a function on another machine. The arguments are copied over, the
%% result is copied back, and a dead node gives you {badrpc, Reason} rather
%% than a hang.
remote_time(Node) ->
    case rpc:call(Node, erlang, localtime, [], 5000) of
        {badrpc, Reason} -> {error, Reason};
        Time -> {ok, Time}
    end.

%% spawn takes an optional node argument. That is the entire API for running
%% a process on another machine.
spawn_there(Node) ->
    spawn(Node, fun() ->
        io:format("running on ~p as ~p~n", [node(), self()])
    end).

%% Sending to a registered name on a named node. The tuple form works whether
%% the process is local or six thousand kilometres away.
tell(Node, Name, Message) ->
    {Name, Node} ! Message,
    ok.

%% Being told when a node joins or leaves. A cluster that cannot be told
%% about a partition is a cluster that will silently do the wrong thing.
watch_nodes() ->
    ok = net_kernel:monitor_nodes(true),
    receive
        {nodeup, Node} -> {joined, Node};
        {nodedown, Node} -> {left, Node}
    after 30000 ->
        quiet
    end.

%% global registers a name across the whole cluster rather than one node.
%% It costs a round of coordination, so use it for a handful of singletons
%% rather than for every process.
register_globally(Name, Pid) ->
    case global:register_name(Name, Pid) of
        yes -> ok;
        no -> {error, already_registered}
    end.

find_globally(Name) ->
    case global:whereis_name(Name) of
        undefined -> not_found;
        Pid -> {ok, Pid}
    end.
Two nodes talking
(alice@laptop)1> node().
alice@laptop
(alice@laptop)2> nodes().
[]
(alice@laptop)3> net_adm:ping('bob@laptop').
pong
(alice@laptop)4> nodes().
[bob@laptop]
(alice@laptop)5> rpc:call('bob@laptop', erlang, node, []).
bob@laptop
(alice@laptop)6> Remote = spawn('bob@laptop', fun() -> receive {ask, From} -> From ! {answer, node()} end end).
<8927.101.0>
(alice@laptop)7> Remote ! {ask, self()}, flush().
Shell got {answer,bob@laptop}
ok
(alice@laptop)8> erlang:disconnect_node('bob@laptop').
true
(alice@laptop)9> nodes().
[]

The pieces

ThingWhat it is
node()this node's name, an atom such as alice@laptop
nodes()everyone we are currently connected to
net_adm:ping(Node)connect, and get pong or pang
{Name, Node} ! Messagesend to a registered name on another node
spawn(Node, Fun)start a process over there
rpc:call(Node, M, F, A)call a function there and wait for the result
erpc:call/4the modern replacement, with better error reporting
global:register_name/2a name unique across the whole cluster
net_kernel:monitor_nodes(true)be told when nodes join and leave
erlang:monitor(process, RemotePid)works across nodes, exactly as it does locally

What distribution does not give you

  1. Netsplits happen and both halves keep running

    When the connection between two nodes drops, each side sees the other go down and carries on. If both halves accept writes, you have two divergent versions of the truth. Erlang gives you the tools to notice, in monitor_nodes, and no opinion at all about what to do next.

  2. global is not a consensus system

    global registers names across the cluster and resolves conflicts after a partition heals by picking a winner and killing the loser, by default. That is fine for singletons you can restart and wrong for anything holding unique state.

  3. Full mesh has a size limit

    Every node connects to every other and heartbeats it, so the connection count grows with the square of the cluster. Somewhere between fifty and a couple of hundred nodes this stops being sensible, and larger systems use a partitioned topology such as the one Discord built, or hidden nodes, or several smaller clusters.

  4. Big messages block the link

    All traffic between two nodes shares one TCP connection by default. A large term sent between them delays everything else, including the heartbeats, which can make a healthy node look dead. Keep messages small, or raise the distribution buffer, or use several connections with erl_dist tuning.

?MODULE:loop(NewState)

Hot code loading

Replacing a module in a running system, keeping every connection and every process state. It is a language feature, not a deployment strategy bolted on afterwards.

The virtual machine keeps two versions of a module in memory: the current one and the previous one. Processes already running old code keep running it. The moment one makes a fully qualified call, meaning module:function(...) rather than a bare function(...), it jumps to the newest version, mid flight, with its state intact.

hot.erl
%% Hot code loading. The BEAM keeps two versions of a module in memory at
%% once: the current one and the old one. Processes running old code keep
%% running it until they make a fully qualified call, at which point they
%% jump to the new version, mid flight, with their state intact.
-module(hot).
-export([start/0, loop/0, greet/1, version/0]).

start() ->
    spawn(?MODULE, loop, []).

loop() ->
    receive
        {greet, From} ->
            From ! {hello, version()},
            %% A fully qualified call, module:function, always goes to the
            %% newest version of the module. A plain loop() would keep this
            %% process on the code it started with forever.
            ?MODULE:loop();
        stop ->
            ok
    end.

greet(Name) ->
    io_lib:format("hello ~s, from version ~p", [Name, version()]).

version() -> 1.

%% Try it in the shell:
%%   Pid = hot:start().
%%   Pid ! {greet, self()}, flush().
%%   %% edit version() to return 2, then:
%%   c(hot).
%%   Pid ! {greet, self()}, flush().
%%
%% The same process answers with the new version and never restarted.
%% Load a third version and any process still running the first is killed,
%% which is the two version rule.
Upgrading a process without restarting it
1> Pid = hot:start().
<0.90.0>
2> Pid ! {greet, self()}, flush().
Shell got {hello,1}
ok
3> % now edit version() to return 2 and recompile, without stopping anything
3> c(hot).
{ok,hot}
4> Pid ! {greet, self()}, flush().
Shell got {hello,2}
ok
5> is_process_alive(Pid).
true
6> code:which(hot).
"/home/you/chat/_build/default/lib/chat/ebin/hot.beam"
7> erlang:check_old_code(hot).
true
8> code:soft_purge(hot).
true

The two version rule

Load a third version and any process still running the first is killed, because there is nowhere left to keep its code. That is why a long running loop should make a fully qualified call every time round: it guarantees the process never falls more than one version behind.

code:purge/1 removes the old version, killing anything still using it. code:soft_purge/1 refuses to do that if anyone is, which is the one to use when you are not certain.

CallDoes
c(Module)compile and load, from the shell
code:load_file(Module)load an already compiled beam
code:which(Module)where the loaded version came from
code:is_loaded(Module)is it in memory
erlang:check_old_code(Module)is there a previous version still around
code:soft_purge(Module)drop the old version, unless someone is running it
code:purge(Module)drop it, killing anyone who is
sys:suspend/resumepause a gen_server across a state change

Upgrading a gen_server's state

Changing a module is easy. Changing the shape of the state a process is holding is the interesting part, and it is what code_change/3 is for. OTP suspends the process, calls your function with the old state and the version you are coming from, and resumes it with whatever you return.

If the counter's state changes from a bare integer to a map, the conversion is one clause: code_change(_Old, Count, _Extra) -> {ok, #{count => Count, updated => erlang:system_time()}}. Every message that arrives after that point sees the new shape.

Releases and relups

  1. For development, c/1 is the whole story

    Recompile and load. This is what makes the shell so productive: the system you are poking at is the system you are changing.

  2. For production, a release upgrade is a described process

    You bump the application version, write an .appup file saying which modules changed and how their state converts, and rebar3 generates a relup. The release handler then applies it to a running node, in order, with a way back.

  3. Most teams do not do that

    Writing and testing appup files for every release is real work, and rolling restarts behind a load balancer are simpler and good enough when a dropped connection is cheap. Hot upgrade earns its keep when a restart is genuinely expensive: long lived stateful connections, telecoms, trading systems, embedded devices you cannot reboot.

  4. Even so, the capability pays for itself in an incident

    Being able to attach to a live node and load a patched module, or add a bit of tracing, without restarting anything, changes how a bad night goes. That is worth knowing about even if you never write an appup.

-spec average([number(), ...]) -> float().

Specs, types and Dialyzer

Erlang is dynamically typed, and there is a static analyser that finds real contradictions in it without ever reporting a false one.

Dialyzer works by success typing. Rather than proving a program is correct, it looks for things that cannot possibly succeed: a function that can only be called with an integer being passed an atom, a case clause that can never match, a return value that is impossible.

The consequence is unusual and worth stating plainly. Dialyzer never complains about code that might work. Everything it reports is a real problem. That means you can adopt it on an existing codebase without a migration, and that a clean run means less than it would in a language with a full type checker.

typed.erl
%% Erlang is dynamically typed, and Dialyzer reads specs to find the
%% contradictions anyway. It never reports a false positive: everything it
%% complains about really is impossible.
-module(typed).
-export([average/1, safe_divide/2, lookup/2, new_handle/0, describe/1]).
-export_type([handle/0, user/0]).

%% A type of your own. The trailing ... means the list cannot be empty, so
%% Dialyzer knows average/1 cannot divide by zero.
-type non_empty_numbers() :: [number(), ...].

-record(user, {id :: pos_integer(), name :: binary(), admin = false :: boolean()}).

-type user() :: #user{}.

%% opaque means other modules may hold one but must not look inside it.
-opaque handle() :: {handle, reference()}.

-spec average(non_empty_numbers()) -> float().
average(Numbers) ->
    lists:sum(Numbers) / length(Numbers).

%% A union return type. The caller sees both possibilities in the spec, and
%% Dialyzer complains if they only match on one of them.
-spec safe_divide(number(), number()) -> {ok, float()} | {error, division_by_zero}.
safe_divide(_Numerator, 0) ->
    {error, division_by_zero};
safe_divide(Numerator, Denominator) ->
    {ok, Numerator / Denominator}.

%% A type variable, introduced with when, so the relationship between the
%% argument and the result is written down rather than implied.
-spec lookup(atom(), #{atom() => Value}) -> {ok, Value} | error when Value :: term().
lookup(Key, Map) ->
    case maps:find(Key, Map) of
        {ok, Value} -> {ok, Value};
        error -> error
    end.

-spec new_handle() -> handle().
new_handle() ->
    {handle, make_ref()}.

-spec describe(user()) -> binary().
describe(#user{name = Name, admin = true}) ->
    <<Name/binary, " (admin)">>;
describe(#user{name = Name}) ->
    Name.

Writing specs

TypeMeans
integer(), float(), number()the obvious
pos_integer(), non_neg_integer()narrower, and Dialyzer uses the difference
atom(), ok | errorany atom, or one of these exactly
binary(), iodata()text, and anything that can be written as text
[integer()]a list of them, possibly empty
[integer(), ...]a list of at least one
{ok, term()} | {error, atom()}a tagged union, the most common Erlang shape
#{name := binary(), age => integer()}a map with a required key and an optional one
fun((integer()) -> boolean())a function value
term(), any()anything, and a signal that you gave up
none()this never returns

-type names a type for reuse. -opaque names one that other modules may hold but must not look inside, which is how you make a handle or an identifier that cannot be forged. -export_type makes either visible outside the module.

Running it
# Start a project. `app` gives you a library, `release` gives you something
# that can be shipped and booted on a server.
rebar3 new release chat
cd chat

rebar3 compile
rebar3 shell           # compiles, then drops you into a shell with the app started
rebar3 eunit           # unit tests
rebar3 ct              # common_test, for anything that needs a running system
rebar3 dialyzer        # static analysis, slow the first time and then cached
rebar3 fmt             # the formatter, if you have added rebar3_format

# Build a release: an entire runtime plus your code, with no Erlang install
# needed on the target machine.
rebar3 as prod release
_build/prod/rel/chat/bin/chat console
_build/prod/rel/chat/bin/chat daemon
_build/prod/rel/chat/bin/chat remote_console   # attach to the running node
?assertEqual(Expected, Expression)

Testing with EUnit and Common Test

EUnit for functions, Common Test for systems, and a language where the interesting logic is easy to test because it never had to touch the world.

The design advice from the earlier chapters pays off here. When the decisions live in functions with no side effects, testing them needs no fixtures, no mocks and no temporary directories. Call it, compare the answer.

test/word_count_tests.erl
%% EUnit finds any function whose name ends in _test or _test_ and runs it.
%% The include is a parse transform, so no exports are needed.
-module(word_count_tests).
-include_lib("eunit/include/eunit.hrl").

tokens_splits_on_punctuation_test() ->
    ?assertEqual([<<"one">>, <<"two">>], word_count:tokens(<<"One, two!">>)).

tally_counts_repeats_test() ->
    ?assertEqual(#{<<"a">> => 2, <<"b">> => 1}, word_count:tally(<<"a b a">>)).

tally_of_nothing_is_empty_test() ->
    ?assertEqual(#{}, word_count:tally(<<>>)).

top_is_ordered_by_count_test() ->
    Text = <<"b a a c c c">>,
    ?assertEqual([{<<"c">>, 3}, {<<"a">>, 2}], word_count:top(Text, 2)).

%% A generator returns a list of tests, which keeps related cases together
%% and gives each one its own line in the report.
punctuation_test_() ->
    [?_assertEqual([<<"a">>], word_count:tokens(<<Text/binary>>))
     || Text <- [<<"a">>, <<" a ">>, <<"a.">>, <<"(a)">>, <<"a\n">>]].

EUnit finds any function whose name ends in _test and runs it, and any function ending in _test_ and treats what it returns as a list of tests. The include is a parse transform, so nothing needs exporting.

MacroChecks
?assert(Expr)it is true
?assertEqual(Expected, Expr)equal, and prints both when not
?assertMatch(Pattern, Expr)matches a pattern, so you can ignore parts
?assertError(Reason, Expr)raises that error
?assertExit(Reason, Expr)exits with that reason
?assertThrow(Term, Expr)throws that term
?_assertEqual(...)the same, deferred, for use inside a generator
?debugVal(Expr)print a value during a test run

Testing processes

A test that starts a gen_server has to stop it again, or the next test finds a registered name already taken. EUnit's fixtures handle that: {setup, fun start/0, fun stop/1, fun tests/1} starts something, runs the tests, and stops it even if they fail.

The harder problem is timing. Tests that sleep for half a second and hope are the main reason Erlang test suites become slow and flaky. Prefer to make the thing you are waiting for observable: a synchronous call that only returns once the work is done, or a monitor on the process you expect to die.

  1. EUnit for anything that fits in one process

    Pure functions, a single gen_server, a module's public interface. Fast, and run on every save.

  2. Common Test for anything that needs a system

    It starts applications, can start several nodes, and gives you suites with setup and teardown per group. Slower, richer, and the right home for a test that starts a supervision tree and kills a child in the middle of it.

  3. PropEr or QuickCheck for properties

    Generate inputs and assert relationships instead of examples: encoding then decoding is the identity, sorting twice is the same as sorting once, a queue never loses a message. Both shrink a failure to the smallest input that still breaks.

  4. Concuerror for concurrency bugs

    It runs a test under every possible interleaving of your processes. When something fails once a week in production and never on your machine, this is the tool that finds it.

rebar.config
{erl_opts, [debug_info, warnings_as_errors]}.

{deps, [
    {cowboy, "2.12.0"},
    {thoas, "1.2.1"}
]}.

{shell, [{apps, [chat]}]}.

{relx, [
    {release, {chat, "0.1.0"}, [chat, sasl]},
    {mode, dev},
    {sys_config, "./config/sys.config"},
    {vm_args, "./config/vm.args"}
]}.

{profiles, [
    {prod, [{relx, [{mode, prod}, {include_erts, true}]}]},
    {test, [{deps, [{proper, "1.4.0"}]}]}
]}.

{dialyzer, [
    {warnings, [unknown, unmatched_returns, error_handling]},
    {plt_extra_apps, [cowboy]}
]}.
observer:start().

Tooling, the ecosystem and where to go next

The build tool, the libraries worth knowing, the instruments for looking inside a running system, and what to read after this.

Erlang's ecosystem is smaller than Python's or JavaScript's and unusually stable. Libraries here tend to be a decade old, still maintained, and still API compatible, partly because OTP itself covers so much of what other languages need packages for.

The commands you will use daily
# Start a project. `app` gives you a library, `release` gives you something
# that can be shipped and booted on a server.
rebar3 new release chat
cd chat

rebar3 compile
rebar3 shell           # compiles, then drops you into a shell with the app started
rebar3 eunit           # unit tests
rebar3 ct              # common_test, for anything that needs a running system
rebar3 dialyzer        # static analysis, slow the first time and then cached
rebar3 fmt             # the formatter, if you have added rebar3_format

# Build a release: an entire runtime plus your code, with no Erlang install
# needed on the target machine.
rebar3 as prod release
_build/prod/rel/chat/bin/chat console
_build/prod/rel/chat/bin/chat daemon
_build/prod/rel/chat/bin/chat remote_console   # attach to the running node

Libraries worth knowing

NeedPackageNote
HTTP servercowboythe default. Built on ranch, which is worth knowing separately as a socket acceptor pool
HTTP clienthackney, or httpchttpc ships with OTP and is fine for modest use
JSONthoas, jsx, jsoneOTP 27 added a json module, which will absorb most of this
Databaseepgsql, mysql-otpplain drivers. There is no ORM tradition here
Connection poolspoolboysmall, old, and does exactly one thing
Metricstelemetrythe events standard shared with the Elixir ecosystem
Loggingloggerin OTP since 21. No package needed
Property testsproperthe open source QuickCheck
Process registrygprocwhen registered atoms are not enough
Distributed databasemnesiain OTP. Good for small shared state, not for large data
An HTTP handler with cowboy

A handler is a module with an init/2. Cowboy runs one process per connection, so a slow request affects nothing else, and a crashing request returns a 500 and takes down only its own process.

json_api.erl
%% A small HTTP handler, using cowboy from Hex. This one needs dependencies,
%% so it is here to show the shape rather than to be run from the shell.
-module(json_api).
-behaviour(cowboy_handler).
-export([init/2]).

init(Req0, State) ->
    Method = cowboy_req:method(Req0),
    Req = handle(Method, Req0),
    {ok, Req, State}.

handle(<<"GET">>, Req) ->
    Body = thoas:encode(#{status => ok, node => atom_to_binary(node())}),
    cowboy_req:reply(
        200,
        #{<<"content-type">> => <<"application/json">>},
        Body,
        Req
    );
handle(_Other, Req) ->
    cowboy_req:reply(405, #{<<"allow">> => <<"GET">>}, <<>>, Req).

Looking inside a running system

observer

observer:start()

A window with every process, every ETS table, scheduler load, memory by allocator, and a live supervision tree. It can connect to a remote node. Open it once in your first week and the runtime stops being abstract.

recon

The production toolkit

recon is the library for looking at a busy system without hurting it: the processes using the most memory, the longest message queues, safe tracing with rate limits. Add it to every project.

sys

sys:get_state and sys:trace

Any OTP process will hand you its current state, or print every message it handles, without a code change. This works in production, on a process that has been running for months.

dbg

Tracing

The BEAM can trace any function call in any process, live. It is astonishingly powerful and easy to bring down a node with, which is why recon_trace exists with limits built in.

Habits that make Erlang pleasant

  1. Keep the shell open

    rebar3 shell starts your application and leaves you inside it. Change a module, call c(module), try it against the running system. The feedback loop is the shortest of any compiled language, and not using it is the most common mistake newcomers make.

  2. Let it crash, and mean it

    Write the match you expect. Do not add a case clause for something that should be impossible, because if it happens you want to know, with a crash report naming the value, rather than to carry on.

  3. One process per concurrent thing

    Per connection, per room, per job, per state machine. Not a pool, unless a pool is limiting access to something genuinely scarce like a database connection.

  4. Design the supervision tree first

    Before writing the workers, decide what must fail together and what must fail alone. The tree is the architecture, and it is much harder to change later.

  5. Write specs and run Dialyzer in CI

    From the first commit. It costs nothing to keep clean and a lot to retrofit.

  6. Prefer call to cast

    Synchronous by default gives you back pressure. Asynchronous everywhere gives you a mailbox that grows until the machine falls over.

Where to go after this

ResourceGood for
Learn You Some Erlang for Great Goodthe friendliest full length introduction, free online and still accurate
Programming Erlang, by Joe Armstrongfrom one of the people who designed it, and worth reading for the reasoning as much as the syntax
Erlang in Angera free book about diagnosing production systems. Read it before you need it
The OTP Design Principlesthe official guide to behaviours, supervision and applications
The module indexthe reference you will keep open. Erlang's documentation is dry and complete
Erlang Forumsan unusually patient community, including several people who wrote the runtime