Erlang — Reference

Source: https://www.erlang.org/docs

Erlang

  • Created: 1986 by Joe Armstrong, Robert Virding, Mike Williams at Ericsson; open-sourced 1998.
  • Latest stable: OTP 28.5 (2025; OTP 28 line). Released on a yearly major-version cadence each May.
  • Paradigms: Concurrent, functional (single-assignment), message-passing, distributed. The “actor model” in production form.
  • Typing: Dynamic, strong; optional gradual typing via Dialyzer (success typing) and Gradualizer.
  • Memory: Per-process heap GC (each process has its own private heap; no stop-the-world). Generational copying collector.
  • Compilation: Source → BEAM bytecode (.beam files) → JIT to native via BeamAsm (default since OTP 24 on x86-64/ARM64). HiPE native compiler removed in OTP 24.
  • Primary domains: Telecom switches, soft real-time systems, messaging (RabbitMQ, ejabberd), distributed databases (CouchDB historically, Riak), payment systems, multiplayer game servers, WhatsApp.
  • Notable runtimes: BEAM (the Erlang VM); shared with Elixir, Gleam, LFE, Hamler, Caramel.
  • Official docs: https://www.erlang.org/docs

At a glance

Erlang exists to keep telephone switches running for decades without restarting. The design follows the “Let it crash” philosophy: cheap processes (hundreds of thousands per node), supervisor trees that restart on failure, hot code reloading that swaps modules in a running system, and transparent distribution across machines via cookies + epmd. Functional core, no shared mutable state — concurrency by message-passing only. OTP (Open Telecom Platform) is the standard library + framework.

Getting started

Installkerl (https://github.com/kerl/kerl) for multi-version on Linux/macOS, asdf with asdf-erlang, or system packages:

# macOS
brew install erlang
# Debian/Ubuntu (Erlang Solutions repo gives newer)
apt install erlang
# Multi-version
kerl build 28.5 28.5 && kerl install 28.5 ~/erlang/28.5
. ~/erlang/28.5/activate

Windows: official installer at https://www.erlang.org/downloads.

Hello world (hello.erl):

-module(hello).
-export([world/0]).
 
world() ->
    io:format("Hello, world!~n").

Compile and run in the shell:

$ erl
1> c(hello).
{ok,hello}
2> hello:world().
Hello, world!
ok

Project layoutrebar3 (https://rebar3.org/) is the de-facto build tool:

$ rebar3 new app myapp
myapp/
  rebar.config
  src/myapp.app.src
  src/myapp_app.erl       % OTP application
  src/myapp_sup.erl       % Top supervisor
  test/
  _build/                 % output

Build/test/release: rebar3 compile, rebar3 eunit, rebar3 ct, rebar3 release, rebar3 shell.

Package managerHex (https://hex.pm/) is shared with Elixir; rebar3 plugin pulls deps from Hex. rebar.config:

{deps, [
    {cowboy, "2.12.0"},
    {jsx, "3.1.0"}
]}.

REPLerl starts the shell; rebar3 shell boots your app’s deps. Live recompile with c(Module), hot reload with l(Module). observer:start(). opens a GUI process inspector.

Basics

Types/literals42 integer (arbitrary precision), 3.14 float, atom_lowercase and 'Quoted Atom' (interned symbols), "string" (== list of char codepoints), <<"binary">> (UTF-8 binary), [1,2,3] list, {ok, Value} tuple, #{a => 1, b => 2} map. Booleans are atoms true/false. PIDs <0.42.0>, references via make_ref().

Variables — Single-assignment, must start with uppercase: X = 1. Bound once per scope; rebinding raises badmatch. _ is wildcard, _Name is a “matched but unused” hint.

Pattern matching — Foundational. = is match, not assignment:

{ok, Value} = some_call(),
[H | T] = [1, 2, 3],
#{a := A} = #{a => 1, b => 2}.

Control flowcase Expr of Patterns end, if Guards end (guards-only, no general expressions), receive Patterns after Timeout -> ... end for messages. No loops — recursion only (BEAM does TCO).

Functions — Multi-clause, pattern-matched, arity-discriminated. Anonymous funs are first-class:

factorial(0) -> 1;
factorial(N) when N > 0 -> N * factorial(N - 1).
 
Double = fun(X) -> X * 2 end,
lists:map(Double, [1,2,3]).

Module-qualified: lists:map/2. Fun references: fun lists:map/2, fun ?MODULE:foo/1.

Strings — Two flavors. Lists of integers ("abc" = [97,98,99]) — the historical default, but heavy. Binaries (<<"abc">>) — packed bytes, the modern preference for performance. unicode:characters_to_binary/1 to convert. string module + binary module.

Collections — Lists [1,2,3] (linked, prepend O(1)), tuples {a,b,c} (fixed-size, indexed element/2), maps #{k => v} (HAMT), records #person{name="Alice"} (compile-time syntax sugar over tuples). Sets/dicts via sets, dict, gb_sets, gb_trees, ordsets.

Intermediate

Type system — Dynamic, but you write type specs with -spec and -type:

-spec add(integer(), integer()) -> integer().
add(X, Y) -> X + Y.

Dialyzer does success typing (proves what cannot succeed) — rebar3 dialyzer. Won’t reject any working code; flags definite mismatches. Gradualizer (community) is a stricter gradual checker.

Modules — One module per file, filename matches -module(name).. -export([f/1, g/2]). declares public API. -import is discouraged (use full mod:fun). Applications group modules into a unit (.app file): name, modules, deps, env. Releases bundle apps + ERTS into a deployable artifact.

Error handling — Three classes: error (bug — error(badarg)), exit (process termination — exit(normal)), throw (non-local return — throw(my_thing)). Catch with try Expr of Patterns catch Class:Reason:Stacktrace -> ... after Cleanup end. But the idiom is let it crash — fail fast, let a supervisor restart you.

Concurrency primitives — Built-ins: spawn(fun() -> ... end) returns a Pid (cheap — ~600 bytes initial heap, millions per node). Send: Pid ! Msg. Receive: pattern-match the inbox. monitor(process, Pid), link(Pid), process_flag(trap_exit, true). OTP behaviors (the standard patterns) layer on top: gen_server (synchronous & async server), gen_statem (state machine, replaces gen_fsm), gen_event (event manager), supervisor (restart strategies: one_for_one, one_for_all, rest_for_one, simple_one_for_one). Application behavior + supervision tree = the “OTP application”.

File I/O & networkingfile:read_file/1, file:open/2, io:read/2. Networking: gen_tcp, gen_udp, ssl (the OTP TLS stack used by Cowboy & RabbitMQ). HTTP: httpc (built-in), Cowboy (https://github.com/ninenines/cowboy) is the canonical server, gun for client.

Stdlib highlightslists, maps, binary, string, re (PCRE wrapper), crypto, ssl, inets, os, proplists, gen_server, supervisor, application, ets, dets, mnesia, sys, sasl, logger (replaced error_logger in OTP 21), observer, dbg, tools.

Advanced

Memory model & GC — Each process has its own heap; messages between processes are copied (except large binaries on the shared binary heap, which are refcounted). GC is per-process, generational copying — never stops the whole system. process_flag(min_heap_size, ...), (min_bin_vheap_size, ...), (fullsweep_after, ...) tune. recon_alloc:fragmentation/1 (from recon) inspects allocator fragmentation.

Concurrency deep dive — Schedulers: by default one OS thread per CPU core, each running a run-queue of ready processes. Reductions (instruction-budget) preempt every ~2000; pure CPU work yields fairly. Dirty schedulers (+SDcpu, +SDio) handle long NIFs without starving normal schedulers. Distributed Erlang: erl -name node@host -setcookie SECRET; nodes connected via net_kernel over TCP/TLS, processes addressed by {Name, Node} tuple. epmd (Erlang Port Mapper Daemon, port 4369) maps node names to ports. Mnesia for replicated transactional storage. ETS (Erlang Term Storage): in-memory tables, concurrent O(1) lookup, read_concurrency/write_concurrency flags. DETS is the disk-backed sibling.

FFI/interop — Two layers:

  • Ports — external OS process speaking a length-prefixed binary protocol over stdin/stdout. Crashes don’t take down the VM. open_port({spawn, "..."}, [...]).
  • NIFs (Native Implemented Functions) — C/Rust code linked into the VM via enif_* API. Fastest, but a NIF crash kills the VM. Long NIFs starve schedulers — use dirty NIFs for >1ms work. Rustler (Elixir/Erlang) for safe Rust NIFs.
  • Port drivers — older, deprecated form. NIFs preferred.
  • C nodes — pretend to be an Erlang node (jinterface for Java).

Reflectionerlang:function_exported/3, erlang:module_loaded/1, code:which/1, M:module_info(). Trace BIFs: erlang:trace/3, erlang:trace_pattern/3 — power tools for dbg, recon_trace, redbug.

Performance toolsobserver (GUI), etop (top-like), recon (https://github.com/ferd/recon — Fred Hébert’s diagnostic toolbox: recon:proc_count/2, recon:bin_leak/1), eflame/eflambe for flamegraphs, fprof/eprof/cprof built-in profilers. erlang:system_info/1 for VM stats. Lock counter: +T 5 and lcnt to find scheduler contention.

God mode

BEAM internals — Files compile to .beam (chunked container: Atom, Code, LitT, LocT, ImpT, ExpT, Dbgi, etc.). The runtime (ERTS) loads modules, hashes them by version, and the JIT (BeamAsm, default since OTP 24, x86-64 + ARM64 backends) lowers each function to native at load. erts_debug:df(Module). writes a dump of the loaded BEAM. Inspect bytecode: erlc -S file.erl produces .S BEAM assembly text.

Hot code reloading — Two versions of any module can be loaded simultaneously: current and old. code:load_file(Mod) loads new code as current, old becomes “old”. A fully-qualified call ?MODULE:foo() (or Mod:foo()) jumps to the current code at every call; bare foo() stays in the version it was compiled into. code:purge(Mod) kills any process still running the old version. release_handler orchestrates appup/relup files for staged upgrades.

OTP behaviors deep dive — Behaviors are interface contracts (-behaviour(gen_server). plus init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3). The library module (gen_server) drives the message loop, calls your callbacks. gen_statem replaces gen_fsm (deprecated) — has state_functions and handle_event_function modes; built-in state-timeouts, postponing events, complex transitions. Supervisor strategies: one_for_one, one_for_all, rest_for_one, simple_one_for_one (now simple_one_for_one is simple_one_for_one-with-children specs-as-templates → use :simple_one_for_one or modern child_spec map). Restart intensity (MaxR/MaxT) caps thrashing.

ETS internals — Backed by hash tables (set, bag, duplicate_bag) or AVL/CA-trees (ordered_set). read_concurrency ↔ wait-free reads via per-scheduler buckets; write_concurrency partitions hash table for fewer write conflicts. Match-spec compiler (ets:fun2ms/1) compiles a fun to a tuple “match spec” interpreted in C — orders of magnitude faster than per-row callbacks.

Distributed Erlang — Cookies (erlang:get_cookie/0) authenticate node peers (no encryption by default — use inet_tls_dist). global registry for cluster-wide names; pg (process groups) for fanout (replaced deprecated pg2). Pids over the wire serialize as {Pid, Node}. Partitions are a real concern — see “split brain” handling, net_kernel:monitor_nodes/1. :ssl_dist wraps the distribution protocol in TLS.

NIFs + dirty schedulersERL_NIF_INIT(modulename, funcs, load, NULL, upgrade, unload). Long NIFs declare ERL_NIF_DIRTY_JOB_CPU_BOUND or _IO_BOUND so the scheduler offloads them. enif_schedule_nif for chunked work. enif_make_resource for opaque GC’d handles. Crash in a NIF = SIGSEGV the whole VM — guard ruthlessly.

Parse transforms — Compile-time AST manipulation. -compile({parse_transform, my_xform}). runs my_xform:parse_transform(Forms, Options) on the abstract forms before code generation. Used by lager (logging), eqc/QuickCheck, stdlib2. Powerful but brittle (depends on undocumented AST shape). epp:parse_file/2 reads forms; erl_syntax/erl_syntax_lib provides higher-level walkers.

Tracingdbg:tracer(), dbg:p(all, c), dbg:tpl(Mod, Fun, '_', x) traces calls + returns + exceptions. recon_trace:calls/2 is the safer production wrapper (rate-limited, auto-stops). Trace events go to a tracer process — can be io, file, or a fun.

erts_debug & inspectionerts_debug:size/1 gives word-count of a term; erts_debug:flat_size/1 the unshared size; erts_debug:df(Mod) dumps loaded module to disk. erlang:system_info(scheduler_id), (reductions), (garbage_collection_info) for runtime introspection.

Elixir / LFE / Gleam interop — All compile to BEAM bytecode and share the same VM. Calling Elixir from Erlang: 'Elixir.MyMod':function(Args). Calling Erlang from Elixir: :my_mod.function(args). Hex packages are shared.

Idioms & style

  • Naming: snake_case for atoms, vars, modules, functions; CamelCase for variables only; ALL_CAPS only for macros (-define).
  • Formatter: erlfmt (https://github.com/WhatsApp/erlfmt) — WhatsApp’s tool, the de-facto modern formatter. rebar3 fmt via plugin.
  • Linter: elvis (https://github.com/inaka/elvis) for style; dialyzer for types; xref (built-in) for cross-reference issues.
  • Idiomatic: pattern-match in function heads, not if; tag results {ok, X} | {error, Reason}; let supervisors handle failure rather than try/catch; use gen_server/gen_statem for stateful processes (don’t roll your own loop); binaries over strings for non-trivial text; ?MODULE instead of literal module name.
  • Reviewer focus: unbounded mailbox growth (receive that doesn’t consume); processes holding large state without GC hints; using lists:foreach for side-effects when [F(X) || X <- Xs] reads better; missing -spec on exported functions; large binaries fragmenting binary heap (need binary:copy/1 for sub-binaries you’ll keep); naked catch swallowing all classes.

Ecosystem

  • Web: Cowboy (HTTP/1.1, HTTP/2, WebSocket — Loïc Hoguin’s server, used by RabbitMQ Mgmt, Riak), Mochiweb (older), Yaws, Elli. Frameworks: Nitrogen (legacy), N2O.
  • Messaging/queues: RabbitMQ (the canonical Erlang application — written in Erlang/OTP), emqx (MQTT broker), VerneMQ.
  • Databases written in Erlang: CouchDB (originally), Riak/Riak KV, AntidoteDB, LeoFS.
  • Realtime/messaging: ejabberd (XMPP), MongooseIM, WhatsApp (custom Erlang stack), WeChat (parts).
  • Build/dist: rebar3 (canonical), erlang.mk (Make-based, used by Cowboy), Hex for packages, relx for releases (now part of rebar3), edeliver for deploys.
  • Testing: EUnit (xUnit, in OTP), Common Test (CT — integration/system tests, in OTP), PropEr (open-source QuickCheck), Triq (older). Meck for mocking.
  • Docs: EDoc (in OTP), ExDoc via Hex (shared with Elixir).
  • Notable users: WhatsApp (3M+ connections per node), Discord (voice infra), Klarna, Goldman Sachs, Bet365, Heroku (routing layer), Pinterest (older), AdRoll, BBC, Ericsson (still — AXD301 ATM switch).

Gotchas

  • Atoms are not garbage-collected — atom table is fixed-size (default ~1M entries). Dynamically creating atoms from untrusted input (list_to_atom/1 on user data) DOSes the node. Use binary_to_existing_atom/2.
  • Hot reload + records — records are compile-time structural; if a record’s shape changes, old code holding the old shape breaks on upgrade. Maps avoid this.
  • Sub-binaries pin parentsbinary:part(Big, 0, 10) keeps Big alive in the binary heap. Use binary:copy/1 if you’ll outlive the parent.
  • Process mailbox is unordered scan-on-pattern-match — selective receive with non-matching messages walks the entire mailbox each time. O(N²) if a slow pattern is starved.
  • == vs =:=== does type-coercive equality (1 == 1.0 is true), =:= is exact (1 =:= 1.0 is false). Always use =:= unless you specifically want coercion.
  • Strings are lists of integers"abc" and [97,98,99] print identically. io:format("~p~n", ["abc"]) outputs "abc"; io:format("~p~n", [[97]]) also outputs "a".
  • Float ÷ → float, integer ÷ → use div/rem5/2 = 2.5, 5 div 2 = 2. / on integers always promotes.
  • Variable already bound — second = is a match, not reassignment; mismatched values throw badmatch. Use _ to discard or rename X1, X2.
  • spawn(Mod, Fun, Args) requires Mod loaded at call site — when distributing, ensure the remote node has the same code.
  • Trapping exits propagates upward as messagestrap_exit, true turns EXIT signals from linked processes into {'EXIT', Pid, Reason} messages; if you don’t handle them, mailbox bloats.
  • NIF + scheduler starvation — a NIF that runs >1ms blocks one of N schedulers; multiply across cores and you drag the whole system. Use dirty schedulers or chunk via enif_schedule_nif.
  • receive ... after 0 — non-blocking poll; common mistake is after Infinity vs no after (both equivalent, but the latter looks more like a bug).

Citations