Learn Erlang
The original BEAM language: dynamic, functional, and built for massively concurrent, fault-tolerant systems.
Getting Started: the Shell and Your First Module
Meet the Erlang shell, write a module, and learn the syntax that trips up newcomers.
Erlang was built at Ericsson in 1986 to run telephone exchanges that must never stop. That heritage shapes everything: it is a small, dynamically typed functional language whose real superpower is running millions of cheap, isolated processes. Before we get to concurrency, let's get comfortable with the basics.
The fastest way to experiment is the shell (a read-eval-print loop). Start it with erl. Every expression you type must end with a period and a space (or newline):
1> 2 + 3 * 4.
14
2> X = 10.
10
3> X = 11.
** exception error: no match of right hand side value 11
That last error is your first big lesson. = is not assignment; it is pattern matching. Once X is bound to 10, matching it against 11 fails. Variables are single-assignment and immutable, and they must start with an uppercase letter. Lowercase words like ok or error are atoms - named constants that stand only for themselves, a bit like symbols or enums.
Real programs live in modules. Create a file named hello.erl (the file name must match the module name):
-module(hello).
-export([greet/1]).
greet(Name) ->
io:format("Hello, ~s!~n", [Name]).
The -module and -export lines are attributes. The greet/1 notation means "the function greet that takes 1 argument" - Erlang identifies functions by name and arity, so greet/1 and greet/2 are completely different functions. Compile and run it from the shell:
1> c(hello).
{ok,hello}
2> hello:greet("World").
Hello, World!
ok
Functions are always called as Module:Function(Args). The ~s is a format directive for a string and ~n is a newline. Notice that io:format returns the atom ok after printing - in Erlang, everything is an expression that evaluates to a value.
Data Types and Pattern Matching
Tuples, lists, maps, and the pattern matching that pulls them apart.
Erlang has a compact set of data types, and pattern matching is the universal tool for working with them.
Numbers, atoms, and strings. Integers have arbitrary precision (no overflow). Atoms are unquoted lowercase words like ok, error, or undefined. A "string" in classic Erlang is really a list of integer character codes, so "abc" equals [97,98,99]. For text you will usually prefer binaries, written <<"abc">>, which are efficient packed byte sequences.
Tuples group a fixed number of values with {}. A near-universal convention is the tagged tuple, where the first element is an atom describing the rest:
Point = {point, 3, 4}.
Result = {ok, 42}.
Failure = {error, not_found}.
Lists hold a variable number of values with []. A list is built from a head (first element) and a tail (the rest), and the | operator ("cons") splits or builds them:
L = [1, 2, 3, 4].
[Head | Tail] = L. %% Head = 1, Tail = [2,3,4]
L2 = [0 | L]. %% [0,1,2,3,4]
Maps are key-value stores written #{}, useful for structured data:
User = #{name => "Ada", age => 36}.
#{name := N} = User. %% N = "Ada"
Note the two operators: => inserts or updates a key, while := matches or updates an existing key.
The real power shows up when matching destructures data and binds variables in one step:
handle({ok, Value}) ->
io:format("Got ~p~n", [Value]);
handle({error, Reason}) ->
io:format("Failed: ~p~n", [Reason]).
This is multi-clause function definition. The clauses are separated by semicolons and ended by a single period; Erlang tries each pattern top to bottom and runs the first that matches. The ~p directive pretty-prints any term. You can also add guards with when to refine a clause:
classify(N) when N < 0 -> negative;
classify(0) -> zero;
classify(N) when N > 0 -> positive.
Guards are restricted to a safe set of side-effect-free tests (comparisons, type checks like is_integer/1, arithmetic), which keeps matching predictable and fast.
Recursion, Higher-Order Functions, and Comprehensions
How to loop without loops, plus funs and list comprehensions.
Erlang has no for or while loops. Because data is immutable, you express repetition with recursion - and the compiler turns tail-recursive calls (where the recursive call is the very last thing a clause does) into efficient iteration that uses constant stack space.
Here is a classic accumulator pattern that sums a list:
-module(lists_demo).
-export([sum/1]).
sum(List) -> sum(List, 0).
sum([], Acc) -> Acc;
sum([H | T], Acc) -> sum(T, Acc + H).
The public sum/1 kicks off a private sum/2 carrying an accumulator. The base clause matches the empty list [] and returns the total; the recursive clause peels off the head H, adds it, and recurses on the tail T. The recursive call is in tail position, so this runs in constant space even for huge lists.
Functions are values. An anonymous function (a "fun") is written with fun:
Double = fun(X) -> X * 2 end.
Double(21). %% 42
The standard lists module provides the higher-order tools you would expect, taking funs as arguments:
lists:map(fun(X) -> X * X end, [1, 2, 3]). %% [1,4,9]
lists:filter(fun(X) -> X rem 2 =:= 0 end, [1,2,3,4]). %% [2,4]
lists:foldl(fun(X, Acc) -> X + Acc end, 0, [1,2,3]). %% 6
A quick note on equality: =:= is exact equality (and =/= its negation), while == does numeric coercion so 1 == 1.0 is true but 1 =:= 1.0 is false. Prefer =:= unless you specifically want coercion.
Finally, list comprehensions offer a concise, readable way to transform and filter in one expression:
[X * X || X <- [1,2,3,4,5], X rem 2 =:= 0].
%% [4, 16]
Read || as "such that": for each X drawn from the list (a generator, <-), keep only those satisfying the filter X rem 2 =:= 0, and collect X * X. You can have multiple generators and filters, making comprehensions a tidy alternative to nested maps and filters.
Processes and Message Passing
Spawn lightweight processes and have them talk by sending messages.
This is what Erlang exists for. The language follows the actor model: a process is the unit of concurrency, and it shares nothing with other processes. Processes communicate only by sending each other asynchronous messages. These are not OS threads - a BEAM process costs only a few hundred bytes, so spawning hundreds of thousands or millions of them is normal.
You create a process with spawn, which returns a process identifier (a pid). You send a message with the ! operator, and you receive with a receive block that pattern matches on incoming messages:
-module(echo).
-export([start/0, loop/0]).
start() ->
spawn(echo, loop, []).
loop() ->
receive
{From, Msg} ->
From ! {self(), "You said: " ++ Msg},
loop();
stop ->
io:format("echo stopping~n")
end.
Each process owns a private mailbox. The receive block scans the mailbox for the first message that matches one of its patterns; unmatched messages stay queued for later. self() returns the current process's pid, and here we recurse into loop() to keep the process alive and ready for the next message. The stop clause has no recursion, so the process simply ends.
Driving it from the shell:
1> Pid = echo:start().
<0.91.0>
2> Pid ! {self(), "hi"}.
{<0.85.0>,"hi"}
3> receive {_, Reply} -> Reply end.
"You said: hi"
4> Pid ! stop.
echo stopping
stop
The request/reply dance - send a message tagged with self() so the server can reply, then receive the answer - is the fundamental pattern under everything in Erlang. Because processes are isolated and message-passing is location-transparent, the same code works whether the other process is on this machine or another node in a cluster. That is the seed of Erlang's famed distribution support.
"Let It Crash": Links, Monitors, and Supervision
Erlang's fault-tolerance philosophy and the OTP framework that codifies it.
Most languages ask you to anticipate and defend against every error. Erlang takes the opposite stance, summed up by Joe Armstrong's slogan "let it crash." Because processes are isolated, one crashing process cannot corrupt another's memory. So instead of wrapping everything in defensive checks, you write the clean happy path and let a process die on unexpected input - then have another process detect the death and react. This concentrates error handling in a few well-defined places and keeps business logic readable.
The primitives are links and monitors. A link is bidirectional: if a linked process dies abnormally, the failure propagates and (by default) kills its partner too. A monitor is one-directional and gentler: the watcher simply receives a 'DOWN' message describing the death, without dying itself.
1> {Pid, Ref} = spawn_monitor(fun() -> exit(boom) end).
{<0.95.0>,#Ref<0.0.0.123>}
2> receive {'DOWN', Ref, process, _, Reason} -> Reason end.
boom
Building reliable systems by hand from spawn, link, and receive is possible but repetitive, so Erlang ships with OTP (Open Telecom Platform) - a set of libraries and design patterns called behaviours. A behaviour splits a process into generic, battle-tested machinery (provided by OTP) and your application-specific callbacks. The most common behaviour is gen_server, a generic server:
-module(counter).
-behaviour(gen_server).
-export([start_link/0, bump/0, init/1, handle_call/3, handle_cast/2]).
start_link() -> gen_server:start_link({local, ?MODULE}, ?MODULE, 0, []).
bump() -> gen_server:call(?MODULE, bump).
init(N) -> {ok, N}.
handle_call(bump, _From, N) ->
{reply, N + 1, N + 1}.
handle_cast(_Msg, N) -> {noreply, N}.
The macro ?MODULE expands to the current module's name. gen_server:call is a synchronous request that waits for a reply; gen_server:cast is fire-and-forget. You only write the callbacks; OTP handles the mailbox loop, timeouts, and edge cases.
The other key behaviour is the supervisor, a process whose only job is to start child processes and restart them when they crash, according to a declared strategy (for example one_for_one: restart just the failed child). Supervisors form a supervision tree, the backbone of every production Erlang system. Combined with the BEAM's ability to hot-load new code into a running system, this is how Erlang software achieves the legendary "nine nines" of uptime: failures are expected, contained, and recovered from automatically rather than prevented at all costs.
Putting It Together and Where to Go Next
A small worked example, plus the tooling and ecosystem to explore.
Let's combine the ideas into a tiny but complete process-based program: a key-value store that lives in its own process and answers requests by message.
-module(kv).
-export([start/0, put/3, get/2, loop/1]).
start() ->
spawn(kv, loop, [#{}]).
put(Pid, Key, Value) ->
Pid ! {put, Key, Value},
ok.
get(Pid, Key) ->
Pid ! {get, self(), Key},
receive {Pid, Reply} -> Reply end.
loop(State) ->
receive
{put, Key, Value} ->
loop(State#{Key => Value});
{get, From, Key} ->
From ! {self(), maps:get(Key, State, undefined)},
loop(State)
end.
The whole "database" is just a map carried as the process's state, threaded through each recursive loop/1 call - the immutable equivalent of a mutable field. The put clause builds an updated map with State#{Key => Value} (map update syntax) and recurses with it; the get clause replies and recurses with the state unchanged. In a real system you would write this as a gen_server under a supervisor, but the essence is identical: state lives inside a process, and the outside world interacts only through messages.
1> Db = kv:start().
<0.88.0>
2> kv:put(Db, color, red).
ok
3> kv:get(Db, color).
red
4> kv:get(Db, missing).
undefined
Tooling and ecosystem. Modern Erlang projects are managed with rebar3, which handles dependencies, builds, releases, and running tests. The package registry is Hex (shared with Elixir). For testing, EUnit covers unit tests and Common Test covers larger integration scenarios. The interactive shell, observer (a graphical runtime inspector), and the dialyzer (a static analysis tool that finds type and discrepancy errors despite Erlang being dynamically typed) round out the daily toolkit.
Where Erlang sits. Everything you have learned runs on the BEAM virtual machine, which today hosts a whole family of languages: Elixir (a Ruby-flavored language with powerful macros), Gleam (a statically typed language with sound type inference), LFE (Lisp on the BEAM), and even Luerl (Lua implemented on the BEAM). They all interoperate because they compile to the same bytecode and share processes, OTP, and the standard library. Learning Erlang teaches you the model underneath all of them.
To go deeper, read Learn You Some Erlang for Great Good! (free online), Joe Armstrong's Programming Erlang, and the official documentation at erlang.org. The single most important habit to build is thinking in processes: small, isolated, message-passing units that fail independently and are supervised back to health. Master that, and you have mastered the soul of the BEAM.