Hello World & the Shell
Hello, World
Two small things to notice: the module-qualified call, and the format directive that is not
\n.let () = print_endline "Hello, World!"io:format("Hello, World!~n").Erlang qualifies every call outside the current module as
module:function(…), so io:format names the io module explicitly — there is no open and no way to bring names into scope. The newline is ~n rather than \n, because the format string is interpreted by io:format rather than by the lexer. And note what is absent: no entry point, no let, and a period rather than a semicolon to end the expression.Formatted Output
The arguments arrive as a list, and the two directives you will use most are
~p and ~s.let name = "OCaml"
let year = 1996
let () = Printf.printf "%s appeared in %d\n" name yearName = "Erlang",
Year = 1986,
io:format("~s appeared in ~p~n", [Name, Year]).OCaml's format string is a typed value the compiler checks against the arguments. Erlang's is an ordinary string and the arguments come as a list, so a mismatch in count or kind is a runtime
badarg. The directives worth knowing: ~p pretty-prints any term and is what you reach for by default, ~s prints a string or binary as text, and ~w writes a term without the pretty-printing. Note the capitalized variable names — that is not a convention, it is the syntax, and the next section is about why.dune and opam vs rebar3
Configuration rather than code, so neither column runs. The interesting line is the last one.
(* dune-project *)
(lang dune 3.16)
(* bin/dune *)
(executable
(name main)
(libraries str))
(* Build and run:
dune build
dune exec bin/main.exe *)%% rebar.config
%% {erl_opts, [debug_info]}.
%% {deps, [{jsx, "3.1.0"}]}.
%%
%% Build and run:
%% rebar3 compile
%% rebar3 shell
%%
%% A RELEASE bundles your code with the whole runtime:
%% rebar3 release
%%
%% and that release can be upgraded IN PLACE, without
%% stopping, which is where the design is heading.rebar3 is Erlang's build tool and Hex is its package registry, which maps onto dune and opam closely enough. The idea with no OCaml counterpart is the release: a bundle of your application together with the runtime itself, which can be started, supervised, connected to from a remote shell, and upgraded while running. That is the shape the whole platform is built around — a system that is expected to stay up across its own deployments, which is a different target from producing a binary.
The Shell Is Part of the System
Both languages have a REPL. Only one of them attaches to a system that is already running and serving traffic.
(* The OCaml toplevel evaluates expressions and is a
development tool. It is not attached to a running
program. *)
let () =
let value = 21 * 2 in
Printf.printf "%d\n" value%% The Erlang shell is a PROCESS in the node, so it can
%% inspect and message any other process — including in a
%% production system, over a remote connection:
%%
%% erl -remsh app@host
%% > sys:get_state(my_server).
%% > erlang:process_info(Pid, message_queue_len).
%%
Value = 21 * 2,
io:format("~p~n", [Value]).The OCaml toplevel is a development tool: it evaluates expressions in a fresh process. The Erlang shell is an ordinary process inside a node, so it can send messages to any other process, inspect state with
sys:get_state, check mailbox lengths, load new code, and do all of that on a live production node over a remote shell. That is a different debugging model — the question "what is this system doing right now" has a direct answer rather than requiring logs and a redeploy. It is also, obviously, a considerable amount of rope.No Types At All
Nothing Is Checked Before It Runs
The largest difference, and it goes further than most dynamic languages: there is no type annotation the compiler enforces at all.
(* This does not compile. The error names the line, the
expected type and the actual one, before anything
runs. *)
let describe value = "value: " ^ string_of_int value
let () = print_endline (describe 42)%% Runs until the bad call is REACHED, then fails.
Describe = fun(Value) -> "value: " ++ integer_to_list(Value) end,
io:format("~s~n", [Describe(42)]),
try Describe(not_a_number) of
Result -> io:format("~s~n", [Result])
catch
error:badarg -> io:format("badarg at runtime~n")
end.OCaml checks every expression before the program starts. Erlang checks nothing ahead of time — and unlike Python's optional hints or TypeScript's erased ones, the language has never had a static type discipline to fall back on. What it has instead is Dialyzer, a success-typing analyzer that finds code which cannot possibly work rather than code that is not provably right, so it reports no false positives and misses a great deal. The specs it reads (
-spec add(integer(), integer()) -> integer().) are documentation the compiler ignores. The real answer to "how is this safe" is the next twelve sections: isolation and supervision rather than proof.Specs Are Documentation
Erlang has a type language, and it exists for a separate analyzer rather than for the compiler.
(* A signature is checked. An .mli that disagrees with
its .ml is a compile error. *)
let add (first : int) (second : int) : int = first + second
let () = Printf.printf "%d\n" (add 3 4)%% A -spec is read by Dialyzer and IGNORED by the
%% compiler. Nothing stops the function being called with
%% anything at all.
%%
%% -spec add(integer(), integer()) -> integer().
%%
Add = fun(First, Second) -> First + Second end,
io:format("~p~n", [Add(3, 4)]).The
-spec syntax is expressive — unions, records, opaque types, function types — and none of it is enforced at compile time or at run time. Dialyzer reads it, plus the code, and reports discrepancies: places where a call can be shown to always fail. Its design principle is that it never reports a false positive, which means it stays quiet about a great deal that OCaml would reject outright. Running it is a separate step most projects do in CI. The comparison an OCaml programmer should hold: this is not gradual typing, it is a linter with a very good theory behind it.Single Assignment & Matching
A Variable Is Bound Exactly Once
The habit that breaks first, and it breaks in a way that produces a confusing error rather than a compile failure.
(* let shadows: a second binding hides the first, and
both exist. This is idiomatic. *)
let () =
let value = 5 in
let value = value * 2 in
let value = string_of_int value ^ " points" in
print_endline value%% There is no shadowing and no rebinding. A second
%% Value = ... would be a MATCH against the first, and
%% would fail. Each step needs a new name.
Value = 5,
Doubled = Value * 2,
Labelled = integer_to_list(Doubled) ++ " points",
io:format("~s~n", [Labelled]).OCaml's
let creates a new binding that hides the old one, so refining a value step by step under one name is idiomatic. In Erlang a variable is bound once per scope, and = is not assignment — it is a pattern match. Writing Value = Value * 2 attempts to match 5 against 10 and raises badmatch. So each step needs its own name, which is why real Erlang is full of Value0, Value1, Value2 — a genuine ergonomic cost, and the reason pipelines are written as nested calls or with a helper function per stage.= Is a Pattern Match
Erlang collapses binding, destructuring and assertion into one operator, and once that clicks the language gets much smaller.
(* Destructuring happens in a let or a match, and the
two are different constructs. *)
let () =
let (status, body) = (200, "ok") in
Printf.printf "%d %s\n" status body;
match (404, "missing") with
| (code, _) when code >= 400 -> Printf.printf "error %d\n" code
| (code, body) -> Printf.printf "%d %s\n" code body%% One operator does both. = matches the right side
%% against the left, binding any unbound variables and
%% checking any bound ones.
{Status, Body} = {200, "ok"},
io:format("~p ~s~n", [Status, Body]),
case {404, "missing"} of
{Code, _} when Code >= 400 -> io:format("error ~p~n", [Code]);
{Code, Text} -> io:format("~p ~s~n", [Code, Text])
end.{Status, Body} = {200, "ok"} binds because both variables are new. {200, Body} = Response would bind Body and assert the first element is 200, raising badmatch if not — which is idiomatic Erlang for "this must be true, and I want to crash here if it is not". OCaml has the same power split across let, match and assertions. Note the case … of … end syntax and that clauses are separated by semicolons with the last ending in end; the guard keyword is when, as in OCaml.No Exhaustiveness Checking
Nothing declares the set of statuses, so nothing can check that you covered it.
(* The compiler proves every constructor is handled.
Deleting a branch names the one you missed. *)
type status = Pending | Active | Closed
let describe = function
| Pending -> "waiting"
| Active -> "running"
| Closed -> "finished"
let () =
List.iter (fun status -> print_endline (describe status))
[ Pending; Active; Closed ]%% There is no type, so there is nothing to be exhaustive
%% over. An unmatched value raises case_clause at run time.
Describe = fun(Status) ->
case Status of
pending -> "waiting";
active -> "running";
closed -> "finished"
end
end,
[io:format("~s~n", [Describe(S)]) || S <- [pending, active, closed]],
try Describe(unknown) of _ -> ok
catch error:{case_clause, _} -> io:format("case_clause at runtime~n")
end.OCaml's
status is a type with three values, and the compiler proves the match covers them. Erlang's pending is an atom, any atom is a valid value, and no declaration exists to be exhaustive over — an unmatched value raises case_clause when it arrives. That sounds worse than it is in practice, for a reason this page keeps returning to: the crash is contained in one process and the supervisor restarts it, so the failure mode is a restarted worker and a log entry rather than a corrupted system. It is a genuinely different bet about where correctness comes from. Note the list comprehension, [Expr || X <- List], which OCaml does not have.Atoms
Atoms Are a Type You Do Not Have
Atoms are the closest thing Erlang has to your polymorphic variants, and they are used far more heavily.
(* The nearest thing is a constant constructor of a
declared variant, or a polymorphic variant tag —
which does not need a declaration. *)
let describe value =
match value with
| `Ok -> "fine"
| `Error -> "not fine"
let () =
print_endline (describe `Ok);
print_endline (describe `Error)%% An atom is a literal constant that IS its own name.
%% No declaration, globally unique, compared in O(1).
Describe = fun(Value) ->
case Value of
ok -> "fine";
error -> "not fine"
end
end,
io:format("~s~n", [Describe(ok)]),
io:format("~s~n", [Describe(error)]).An atom is a constant whose value is its own name, written in lower case (or quoted:
'hello world'). It needs no declaration, is unique across the whole system, and compares in constant time because it is interned. OCaml's polymorphic variants are the nearest analogue — also undeclared, also structurally typed — but OCaml tracks which tags a value may carry and Erlang does not, so an atom is genuinely just a value. Atoms carry an enormous amount of Erlang: ok, error, undefined, every message tag, every module and function name. One caution: the atom table is not garbage collected, so never create atoms from untrusted input.Tagged Tuples Replace Variants
The tagged tuple is Erlang's algebraic data type, held together by convention rather than by a declaration.
type shape =
| Circle of float
| Rectangle of float * float
| Point
let area = function
| Circle radius -> 3.14159 *. radius *. radius
| Rectangle (width, height) -> width *. height
| Point -> 0.0
let () =
List.iter
(fun shape -> Printf.printf "%.2f\n" (area shape))
[ Circle 1.0; Rectangle (2.0, 3.0); Point ]%% An atom in the first position of a tuple is the tag.
%% This is the universal Erlang convention and it is what
%% a variant becomes.
Area = fun(Shape) ->
case Shape of
{circle, Radius} -> 3.14159 * Radius * Radius;
{rectangle, Width, Height} -> Width * Height;
point -> 0.0
end
end,
[io:format("~.2f~n", [Area(S)])
|| S <- [{circle, 1.0}, {rectangle, 2.0, 3.0}, point]].A tuple whose first element is an atom is the universal Erlang idiom —
{ok, Value}, {error, Reason}, {circle, Radius} — and it maps onto an OCaml constructor with a payload exactly. What is missing is the declaration: nothing lists the shapes, nothing prevents a fourth, nothing checks the arity of the payload, and a typo in the tag simply fails to match. In exchange the tags cost nothing to introduce, travel between processes and across machines unchanged, and can be pattern-matched by code that has never heard of them.Tuples, Lists & Maps
Lists
The same immutable linked list, with commas instead of semicolons and a vertical bar instead of
::.let () =
let numbers = [ 1; 2; 3 ] in
let extended = 0 :: numbers in
List.iter (Printf.printf "%d ") extended;
print_newline ();
Printf.printf "length = %d\n" (List.length extended)Numbers = [1, 2, 3],
Extended = [0 | Numbers],
[io:format("~p ", [N]) || N <- Extended],
io:format("~nlength = ~p~n", [length(Extended)]).Both are singly linked immutable lists, so consing is constant time and length is a traversal. The syntax differs in three ways: elements are comma-separated, the cons is
[Head | Tail] rather than Head :: Tail, and the pattern form is the same bracket syntax ([First, Second | Rest]), which is more flexible than OCaml's. Erlang lists are heterogeneous — [1, atom, "text"] is a perfectly good list — where OCaml requires one element type. And length/1 is a plain function rather than List.length, because the commonest list operations are auto-imported.Maps
Maps have literal syntax, need no functor, and — the part with no OCaml counterpart — can be pattern-matched.
module StringMap = Map.Make (String)
let () =
let ages = StringMap.add "alan" 41 (StringMap.add "ada" 36 StringMap.empty) in
Printf.printf "entries = %d\n" (StringMap.cardinal ages);
match StringMap.find_opt "ada" ages with
| Some age -> Printf.printf "ada is %d\n" age
| None -> print_endline "unknown"Ages = #{ada => 36, alan => 41},
io:format("entries = ~p~n", [maps:size(Ages)]),
case maps:find(ada, Ages) of
{ok, Age} -> io:format("ada is ~p~n", [Age]);
error -> io:format("unknown~n")
end,
%% Maps also pattern match, which is the real advantage:
#{ada := Found} = Ages,
io:format("matched ~p~n", [Found]).OCaml's
Map.Make (String) builds a module specialized to string keys and every operation goes through it. Erlang's map is a built-in type with literal syntax, keys of any type, and maps:find/2 returning {ok, Value} or the atom error — which is the tagged-tuple convention doing what an option does. The thing OCaml cannot do at all is the last two lines: a map is a pattern, so #{ada := Found} = Ages destructures it, and a function clause can match on the presence and value of specific keys. That makes maps usable where OCaml would need a record.Records Are a Compile-Time Fiction
Erlang has records, and they are a preprocessor trick rather than a type — which is why most modern code uses maps instead.
(* A record is a real type with named fields, checked
by the compiler and hidden behind a signature if you
want. *)
type point = { x : int; y : int }
let () =
let origin = { x = 0; y = 0 } in
let shifted = { origin with x = 5 } in
Printf.printf "%d %d\n" origin.x shifted.x%% An Erlang record is a TUPLE with names the compiler
%% substitutes away. It is declared with -record and, at
%% run time, #point{x=0, y=0} is just {point, 0, 0}.
%%
%% -record(point, {x = 0, y = 0}).
%% Origin = #point{x = 0, y = 0},
%% Shifted = Origin#point{x = 5},
%%
%% Modern code often prefers a map, which needs no
%% declaration and can be matched on directly:
Origin = #{x => 0, y => 0},
Shifted = Origin#{x := 5},
io:format("~p ~p~n", [maps:get(x, Origin), maps:get(x, Shifted)]).A
-record declaration creates no type: the compiler rewrites #point{x=0} into a tuple {point, 0, 0} and field access into element positions. That means records are fast and are also invisible at run time, cannot be inspected generically, and require every module that uses one to include the same header file. Maps came later and are what most new code uses — no declaration, no header, matchable, and printable. OCaml's record is a genuine type the compiler checks and a signature can hide, which neither Erlang option offers. The Origin#{x := 5} syntax is the map update, and := requires the key to already exist while => inserts.A "String" Is a List of Integers
The oldest and most surprising thing about Erlang data, and the reason binaries exist.
(* A string is a compact immutable byte array with its
own type and its own module. *)
let () =
let text = "abc" in
Printf.printf "length = %d\n" (String.length text);
Printf.printf "first = %d\n" (Char.code text.[0])%% "abc" is [97, 98, 99] — a cons cell per character. It
%% is a list, and every list function works on it.
Text = "abc",
io:format("length = ~p~n", [length(Text)]),
[First | _] = Text,
io:format("first = ~p~n", [First]),
%% Which is why io:format prints it as a list of numbers
%% unless you ask for ~s:
io:format("~p and ~s~n", [Text, Text]).A double-quoted literal in Erlang is a list of integer code points —
"abc" and [97, 98, 99] are the same value, indistinguishable. That is the same design as Haskell's String and has the same cost: one cons cell per character, and no way to tell a string from a list of small numbers, which is why io:format guesses and why ~p sometimes prints a word and sometimes prints digits. Real text uses binaries (<<"abc">>), covered in their own section, and modern code uses them nearly everywhere.List Comprehensions
Generators and filters in one expression, with no nesting per generator — something OCaml has never grown.
(* No comprehension syntax: compose combinators, and
nest one level per generator. *)
let () =
let pairs =
List.concat_map
(fun first ->
List.filter_map
(fun second -> if first < second then Some (first, second) else None)
[ 1; 2; 3 ])
[ 1; 2; 3 ]
in
List.iter (fun (first, second) -> Printf.printf "(%d,%d) " first second) pairs;
print_newline ()Pairs = [{First, Second}
|| First <- [1, 2, 3],
Second <- [1, 2, 3],
First < Second],
[io:format("(~p,~p) ", [F, S]) || {F, S} <- Pairs],
io:format("~n").The comprehension reads as set-builder notation: generators with
<-, filters as bare boolean expressions, and the result at the front. Each extra generator costs a line rather than a nesting level, where OCaml has to compose concat_map with filter_map and indent for each. Erlang also has binary comprehensions (<< <<(X*2)>> || <<X>> <= Binary >>), which generate and consume binaries the same way and have no counterpart anywhere else on this anchor.Binaries & Bit Syntax
Binaries Are a First-Class Type
The
<< >> syntax is a binary, and the second line is a pattern match against its bytes.(* A string is an immutable byte sequence, and Bytes is
its mutable counterpart. Neither has pattern syntax. *)
let () =
let text = "hello" in
Printf.printf "%d bytes\n" (String.length text);
Printf.printf "first = %d\n" (Char.code text.[0])Text = <<"hello">>,
io:format("~p bytes~n", [byte_size(Text)]),
<<First, _/binary>> = Text,
io:format("first = ~p~n", [First]).A binary is a packed, immutable byte sequence — the type Erlang actually uses for text and for network data, because a "string" in the older sense is a list of integers, one cons cell per character, which is as wasteful as Haskell's
String. What has no OCaml counterpart is that a binary is matchable: <<First, _/binary>> binds the first byte and ignores the rest, and the same syntax constructs. OCaml handles bytes with Bytes and Bigarray and offers no pattern syntax for them at all.Bit Syntax Parses Protocols
The feature Erlang is quietly famous for, and the clearest thing on this page that OCaml simply cannot express.
(* Parsing a packed header means arithmetic on bytes,
with the shifts and masks written out by hand. *)
let () =
let packet = [| 0; 200; 5 |] in
let length = (packet.(0) lsl 8) lor packet.(1) in
let kind = packet.(2) in
Printf.printf "length %d kind %d\n" length kind%% Field widths declared in the pattern, in BITS. Here a
%% 16-bit length followed by an 8-bit type — the shape of
%% almost every binary protocol header.
Packet = <<0, 200, 5>>,
<<Length:16, Kind:8>> = Packet,
io:format("length ~p kind ~p~n", [Length, Kind]).
%% Widths can go BELOW a byte, which is what makes the
%% feature remarkable — an IPv4 header starts:
%% <<Version:4, HeaderLength:4, ServiceType:8,
%% TotalLength:16, Identification:16, ...>>Bit syntax lets a pattern name field widths in bits, so a packed protocol header is matched by writing out its layout, with endianness and signedness available as modifiers and construction working the same way. The runnable example is byte-aligned, and the comment shows where the feature actually earns its reputation: widths below a byte, so
<<Version:4, HeaderLength:4, ServiceType:8, TotalLength:16, …>> parses an IPv4 header in one expression. The OCaml column shows the alternative — shifts and masks by hand, one line per field, with the widths implicit in the constants. This is why Erlang is used for telecoms protocol work, and it is a genuine capability gap rather than a matter of taste.Functions & Clauses
Function Clauses
Erlang dispatches on patterns in the function head, which reads like Haskell's multiple equations rather than OCaml's single one.
let rec length_of list =
match list with
| [] -> 0
| _ :: rest -> 1 + length_of rest
let () = Printf.printf "%d\n" (length_of [ 1; 2; 3 ])%% A named function is defined by CLAUSES, each with its
%% own pattern. In a module they would be separate lines;
%% in an expression, one fun with several clauses.
LengthOf = fun Self(List) ->
case List of
[] -> 0;
[_ | Rest] -> 1 + Self(Rest)
end
end,
io:format("~p~n", [LengthOf([1, 2, 3])]).In a module this would be two clauses —
length_of([]) -> 0; and length_of([_|Rest]) -> 1 + length_of(Rest). — separated by a semicolon and ending with a period, which is the idiomatic form. Because the page evaluates expressions rather than modules, the anchor column's case shape is used instead, with fun Self(…) naming the function so it can recurse. Note that Erlang functions are identified by name and arity: foo/1 and foo/2 are entirely different functions, which is why documentation always writes the arity.Guards Are Restricted
Both languages have guards, and Erlang's are deliberately much less powerful.
(* A guard may call any function, including one you
wrote, and may do anything. *)
let expensive number = number mod 7 = 0
let classify number =
match number with
| 0 -> "zero"
| n when n < 0 -> "negative"
| n when expensive n -> "divisible by seven"
| _ -> "ordinary"
let () =
List.iter (fun n -> Printf.printf "%d is %s\n" n (classify n))
[ 0; -5; 14; 3 ]%% A guard may only use BUILT-IN tests and operators —
%% no user function, no side effect, and a failing guard
%% silently skips the clause rather than raising.
Classify = fun(Number) ->
case Number of
0 -> "zero";
N when N < 0 -> "negative";
N when N rem 7 =:= 0 -> "divisible by seven";
_ -> "ordinary"
end
end,
[io:format("~p is ~s~n", [N, Classify(N)]) || N <- [0, -5, 14, 3]].An OCaml
when guard may call anything. An Erlang guard may use only a fixed set of built-in tests (is_integer, is_list, is_atom), comparisons and arithmetic — calling your own function in a guard is a syntax error. The reason is that guards must be free of side effects and guaranteed to terminate, because they run inside pattern matching including in receive. A guard that raises does not propagate the error; it just fails, and the next clause is tried. Note =:=, which is exact equality; == compares 1 and 1.0 as equal and is almost never what you want.No Currying, No Partial Application
Another OCaml habit that does not survive: an Erlang function of two arguments needs two.
let add first second = first + second
let () =
let add_ten = add 10 in
Printf.printf "%d\n" (add_ten 5);
Printf.printf "%d\n" (add_ten 32)%% Functions take all their arguments at once. Partial
%% application means writing the closure out.
Add = fun(First, Second) -> First + Second end,
AddTen = fun(Second) -> Add(10, Second) end,
io:format("~p~n", [AddTen(5)]),
io:format("~p~n", [AddTen(32)]).Erlang has no currying, so
Add(10) is a call to a function of arity 1 that does not exist — an undef error rather than a partial application. Writing the closure out is the whole answer; there is no partial helper because arity is part of a function's identity, so a generic one cannot be written. The related idiom is fun Module:Function/Arity, which names an existing function as a value: lists:map(fun erlang:integer_to_list/1, Numbers).Recursion
Tail Calls Are Guaranteed
Both guarantee constant stack space for a tail call, and in Erlang the guarantee is load-bearing.
let rec count_down counted current =
if current = 0 then counted
else count_down (counted + 1) (current - 1)
let () = Printf.printf "%d\n" (count_down 0 10000)CountDown = fun Self(Counted, Current) ->
case Current of
0 -> Counted;
_ -> Self(Counted + 1, Current - 1)
end
end,
io:format("~p~n", [CountDown(0, 10000)]).OCaml eliminates tail calls as a language property, and so does Erlang. The difference is what rests on it: every long-running Erlang process is an infinite tail-recursive loop, receiving a message, computing a new state and calling itself. Without the guarantee the entire process model would be impossible. That is why the BEAM makes it a promise rather than an optimization, and why an Erlang programmer thinks about it constantly where an OCaml programmer thinks about it occasionally.
Working Over Lists
The same three functions, plus a comprehension that does the whole thing and is what Erlang programmers actually write.
let () =
[ 1; 2; 3; 4; 5; 6 ]
|> List.filter (fun number -> number mod 2 = 0)
|> List.map (fun number -> number * 2)
|> List.fold_left ( + ) 0
|> Printf.printf "total = %d\n"Numbers = [1, 2, 3, 4, 5, 6],
Evens = lists:filter(fun(N) -> N rem 2 =:= 0 end, Numbers),
Doubled = lists:map(fun(N) -> N * 2 end, Evens),
Total = lists:foldl(fun(N, Sum) -> N + Sum end, 0, Doubled),
io:format("total = ~p~n", [Total]),
%% Or as a comprehension, which is the idiomatic form:
io:format("total = ~p~n",
[lists:sum([N * 2 || N <- Numbers, N rem 2 =:= 0])]).The
lists module maps onto OCaml's List closely, with the notable difference that lists:foldl passes the element first and the accumulator second, which is the reverse of List.fold_left and a reliable source of confusion. There is no pipeline operator: Erlang has no |> and no currying to make one useful, so pipelines are nested calls or a chain of intermediate variables. The list comprehension covers most of what a pipeline would, with filters as bare guards after the generator.Errors & Let It Crash
Let It Crash
The philosophical center of the language, and it is the opposite of what a type-driven programmer is trained to do.
(* Defensive: check the input and return a result the
caller must handle. *)
let parse_positive text =
match int_of_string_opt text with
| None -> Error "not a number"
| Some number when number <= 0 -> Error "must be positive"
| Some number -> Ok number
let () =
match parse_positive "42" with
| Ok number -> Printf.printf "%d\n" number
| Error message -> print_endline message%% Non-defensive: match what you expect and let the
%% process die if it is not that. The supervisor restarts
%% it from a known-good state.
ParsePositive = fun(Text) ->
Number = list_to_integer(Text), %% raises on bad input
true = Number > 0, %% raises on non-positive
Number
end,
io:format("~p~n", [ParsePositive("42")]),
%% Catching is possible and is deliberately NOT the default:
try ParsePositive("oops") of N -> io:format("~p~n", [N])
catch error:badarg -> io:format("the process would have died~n")
end.OCaml's discipline is to make failure impossible or explicit: return a
result, handle every case, keep the function total. Erlang's is to write only the happy path and let the process die when reality disagrees — because a process is cheap, isolated, and supervised, so a crash costs a restart from a known-good state rather than a corrupted system. The argument behind it is that defensive code for situations you did not anticipate is itself unreliable, and that the code you did not write cannot be wrong. It works because of the runtime, not instead of it, which is why the next four sections matter.When You Do Return an Error
For failures a caller is genuinely expected to handle, Erlang uses a tagged tuple — its
result, by convention.let parse text =
match int_of_string_opt text with
| Some number -> Ok number
| None -> Error (Printf.sprintf "%S is not a number" text)
let () =
(match parse "42" with
| Ok number -> Printf.printf "%d\n" number
| Error message -> print_endline message);
(match parse "oops" with
| Ok number -> Printf.printf "%d\n" number
| Error message -> print_endline message)%% The {ok, Value} / {error, Reason} convention is what a
%% result type looks like without a type system.
Parse = fun(Text) ->
try {ok, list_to_integer(Text)}
catch error:badarg -> {error, not_a_number}
end
end,
Describe = fun(Text) ->
case Parse(Text) of
{ok, Number} -> io:format("~p~n", [Number]);
{error, Reason} -> io:format("~p~n", [Reason])
end
end,
Describe("42"),
Describe("oops").{ok, Value} and {error, Reason} are so universal that the whole standard library follows them, which makes them as reliable as a type in practice and as unenforced as any other convention. The distinction Erlang draws is worth adopting even in OCaml: expected failures are values, unexpected ones are crashes, and conflating them is what produces both over-defensive code and swallowed errors. What is missing compared with OCaml's result is composition — there is no bind, no let*, so chaining several fallible steps means nested case expressions.Three Kinds of Failure
Erlang splits failure into three classes, and the distinction is about who is expected to deal with it.
(* One mechanism: exceptions, raised and caught by
pattern. raise, failwith and assert all produce one. *)
exception Custom of string
let () =
(try raise (Custom "mine") with Custom text -> Printf.printf "caught %s\n" text);
(try failwith "boom" with Failure text -> Printf.printf "caught %s\n" text);
(try ignore (1 / 0) with Division_by_zero -> print_endline "caught division")%% error, exit and throw are three distinct classes, and a
%% catch clause names which one it wants.
try throw(mine) catch throw:What -> io:format("caught ~p~n", [What]) end,
try exit(boom) catch exit:Why -> io:format("caught ~p~n", [Why]) end,
try 1 div 0 catch error:Reason -> io:format("caught ~p~n", [Reason]) end.error is a runtime fault — a bad match, a bad argument, a division by zero — and carries a stack trace. exit is a process terminating, and is what links and monitors report. throw is a non-local return the author intended, for control flow. OCaml has one mechanism and the distinction lives in naming conventions instead. The practical value of the split is that catch error:_ does not accidentally swallow a deliberate throw, and a supervisor can distinguish "this process was asked to stop" from "this process broke".Processes
Processes Are the Unit of Everything
Everything else in this page follows from how cheap a process is.
(* A domain is an OS thread with its own minor heap.
Create a few, not thousands. *)
let () =
let worker = Domain.spawn (fun () -> 21 * 2) in
Printf.printf "%d\n" (Domain.join worker)%% A process costs a few hundred words. Millions are
%% ordinary, and they are the unit of concurrency,
%% isolation, error handling AND state.
Parent = self(),
spawn(fun() -> Parent ! {result, 21 * 2} end),
receive
{result, Value} -> io:format("~p~n", [Value])
after 1000 -> io:format("timed out~n")
end.An OCaml 5
Domain is an operating-system thread with its own minor heap, so the guidance is a handful. An Erlang process is a runtime-level structure costing a few hundred words, with its own heap and its own garbage collector — so a process per connection, per user, per state machine is the normal design, and systems with millions are routine. That cheapness is what makes the rest possible: isolation, supervision and "let it crash" are only affordable because restarting a process is cheap. Note self() and ! for send, and that receive blocks until a matching message arrives.State Lives in a Process, Not a Cell
Erlang has no mutable variable of any kind. This loop is what a
ref becomes, and it is the shape of every stateful thing in the language.(* Mutable state is a ref: a heap cell any holder can
read and write, with a mutex if shared. *)
let () =
let counter = ref 0 in
let bump () = counter := !counter + 1 in
bump (); bump (); bump ();
Printf.printf "%d\n" !counter%% There is no mutable cell in the language. State is a
%% process looping over its own value, and the only way to
%% reach it is a message.
Counter = spawn(fun() ->
Loop = fun Self(Total) ->
receive
bump -> Self(Total + 1);
{get, Caller} -> Caller ! {total, Total}, Self(Total)
end
end,
Loop(0)
end),
Counter ! bump, Counter ! bump, Counter ! bump,
Counter ! {get, self()},
receive {total, Value} -> io:format("~p~n", [Value])
after 1000 -> io:format("timed out~n") end.OCaml's
ref is a heap cell that any holder can write, needing a mutex once shared. Erlang has nothing equivalent — no cell, no assignment, no shared memory at all. State is a process holding a value and tail-calling itself with the next one, and the only way in is a message, so access is serialized by construction and a data race is not expressible. That is why the tail-call guarantee is load-bearing, and it is the pattern OTP's gen_server generalizes. The cost is that reading a value is a message round trip, which is why Erlang also has ETS for shared tables when that cost is too high.Registering a Name
A registered name turns a process into something any code in the node can address, without being handed a reference.
(* A domain handle is a value you must thread through
whatever needs it. There is no global registry. *)
let () =
let worker = Domain.spawn (fun () -> 42) in
(* Anything wanting to reach this must be given the handle. *)
Printf.printf "%d\n" (Domain.join worker)%% A process can be registered under an atom, and then
%% anything in the node can reach it by that name.
Counter = spawn(fun() -> receive {get, C} -> C ! {value, 42} end end),
register(counter, Counter),
counter ! {get, self()},
receive {value, Value} -> io:format("~p~n", [Value])
after 1000 -> io:format("timed out~n") end.register/2 binds an atom to a process for the whole node, so counter ! Message works from anywhere — and if the process dies, the name is released and sending to it raises badarg. That is how OTP's named servers are reached, and it is the closest thing Erlang has to a global. The cost is the usual one for globals: a registered name is a single instance per node, so anything needing several must be addressed by pid or through a registry process. OCaml has no equivalent because a domain handle is an ordinary value you thread through.Message Passing
Selective Receive
A
receive does not take the next message — it takes the first one that matches, which is a much stronger primitive than it sounds.(* A queue delivers in order. Taking the second item
before the first means draining and re-queuing by
hand. *)
let () =
let queue = Queue.create () in
Queue.add (`Low "later") queue;
Queue.add (`High "now") queue;
(* Take them in arrival order; priority needs sorting. *)
Queue.iter (function
| `Low text -> Printf.printf "low: %s\n" text
| `High text -> Printf.printf "high: %s\n" text) queue%% receive scans the mailbox for the FIRST message that
%% matches, leaving the others in place. Priority handling
%% needs no queue manipulation at all.
self() ! {low, "later"},
self() ! {high, "now"},
receive {high, Text} -> io:format("high: ~s~n", [Text]) after 1000 -> ok end,
receive {low, Text2} -> io:format("low: ~s~n", [Text2]) after 1000 -> ok end.Each process has a mailbox, and
receive scans it in order for the first message matching any of its patterns, leaving non-matching messages in place for a later receive. That makes priority handling, request/response correlation and protocol state machines trivial: a process expecting a reply to request 7 simply receives on {reply, 7, Result} and everything else waits. OCaml's queues and channels deliver in order, so the same behavior means draining, inspecting and re-queuing by hand. The caveat is performance: a selective receive scans the mailbox, so a process with a large backlog and a narrow pattern can go quadratic.Messages Are Copied
The design decision that makes everything else safe, and the one with a real cost attached.
(* Domains share a heap, so passing a value passes a
pointer and both see the same data. A mutex is what
keeps that safe. *)
let () =
let shared = [| 1; 2; 3 |] in
let worker = Domain.spawn (fun () -> Array.length shared) in
Printf.printf "%d\n" (Domain.join worker)%% Every message is COPIED into the receiving process's
%% own heap. Nothing is shared, so nothing needs locking
%% and no process can corrupt another.
Data = [1, 2, 3],
Parent = self(),
spawn(fun() -> Parent ! {size, length(Data)} end),
receive {size, Size} -> io:format("~p~n", [Size]) after 1000 -> ok end.OCaml 5 domains share one heap, so passing a structure passes a pointer and correctness depends on locking. Erlang copies every message into the receiver's own heap, so no two processes ever reference the same mutable data — which is why there are no locks, no data races, and no need for a race detector. It is also why each process can be garbage collected independently, so a collection pauses one process rather than the world. The cost is real: sending a large structure copies it. The exceptions are large binaries, which are reference-counted and shared, and ETS tables, which are explicitly shared storage.
Timeouts Are Built Into receive
A deadline is part of the receive construct rather than a separate mechanism, which is why Erlang needs no timer library.
(* Waiting with a deadline means a library — Eio, Lwt or
a condition variable with a timed wait built by hand. *)
let () =
let worker = Domain.spawn (fun () -> 42) in
Printf.printf "%d\n" (Domain.join worker)%% after is part of receive, so a deadline needs nothing
%% extra — and "after 0" makes it a non-blocking poll.
receive
Anything -> io:format("got ~p~n", [Anything])
after 100 ->
io:format("nothing arrived in 100ms~n")
end,
%% A sleep is a receive that never matches:
receive after 10 -> io:format("slept~n") end.after Milliseconds -> … is a clause of receive, so every wait can carry a deadline at no cost. after 0 makes it a non-blocking poll of the mailbox, and a receive with only an after clause is how you sleep. That uniformity — waiting, timing out and sleeping being one construct — is the same design instinct behind Go's select, arrived at independently and twenty years earlier. OCaml has no timed wait in the standard library at all; Eio and Lwt supply one, and building it from Condition by hand is fiddly to get right.Links, Monitors & Supervision
Watching Another Process Die
Failure arrives as a message, which is what lets supervision be written in ordinary code.
(* Domain.join re-raises an exception from the domain in
the joining thread, which is the only notification
available. *)
let () =
let worker = Domain.spawn (fun () -> failwith "boom") in
(try ignore (Domain.join worker) with
| e -> Printf.printf "caught: %s\n" (Printexc.to_string e))%% A monitor delivers a MESSAGE when the watched process
%% exits, whatever the reason — so failure is an ordinary
%% message rather than an exception.
Worker = spawn(fun() -> exit(boom) end),
_Reference = erlang:monitor(process, Worker),
receive
{'DOWN', _Ref, process, _Pid, Reason} ->
io:format("caught: ~p~n", [Reason])
after 1000 -> io:format("timed out~n")
end.OCaml's
Domain.join re-raises the domain's exception in the joining thread, so the parent must be joining at the moment it wants to know. An Erlang monitor is one-directional and asynchronous: the watcher receives a 'DOWN' message whenever the watched process exits, for any reason, and can be doing something else in the meantime. A link is the bidirectional version — if either end dies abnormally the other is killed too, unless it has trapped exits. Those two primitives are the whole of the failure-propagation model, and supervisors are ordinary processes built from them.Supervision Trees
Both columns implement a restart policy by hand. In Erlang you would not — OTP supplies it, declaratively.
(* There is no supervision concept. Restarting failed
work means writing the retry loop, deciding the
policy, and keeping it correct yourself. *)
let () =
let rec attempt remaining =
if remaining = 0 then print_endline "gave up"
else
match Domain.join (Domain.spawn (fun () -> failwith "boom")) with
| exception _ ->
Printf.printf "restarting, %d left\n" (remaining - 1);
attempt (remaining - 1)
| () -> print_endline "ok"
in
attempt 2%% A supervisor is a process whose only job is to start
%% children and restart them by a declared strategy. This
%% is the shape, written by hand:
Supervise = fun Self(Restarts) ->
Child = spawn(fun() -> exit(boom) end),
erlang:monitor(process, Child),
receive
{'DOWN', _, process, _, _} when Restarts > 0 ->
io:format("restarting, ~p left~n", [Restarts - 1]),
Self(Restarts - 1);
{'DOWN', _, process, _, _} ->
io:format("gave up~n")
after 1000 -> io:format("timed out~n")
end
end,
Supervise(2).The real version is a
supervisor behavior: you declare the children, the restart strategy (one_for_one, one_for_all, rest_for_one) and the intensity — how many restarts in what period before the supervisor itself gives up and escalates to its supervisor. That escalation is why it is a tree: failure propagates upward until some level can restart from a state known to be good. There is no OCaml equivalent at any level, and building one is not simply a library exercise, because it depends on cheap isolated processes with independent heaps.Trapping Exits
The single flag that turns a process into something able to supervise others.
(* An exception in a domain surfaces at Domain.join and
nowhere else. There is no way to be notified without
joining. *)
let () =
let worker = Domain.spawn (fun () -> failwith "boom") in
(try ignore (Domain.join worker) with
| e -> Printf.printf "caught: %s\n" (Printexc.to_string e))%% A linked process normally DIES with its partner.
%% Trapping exits converts that into a message instead —
%% which is exactly what makes a supervisor possible.
process_flag(trap_exit, true),
_Worker = spawn_link(fun() -> exit(boom) end),
receive
{'EXIT', _Pid, Reason} -> io:format("caught: ~p~n", [Reason])
after 1000 -> io:format("timed out~n")
end.A link is bidirectional: if either end dies abnormally the other is killed too, which by default propagates failure through a group of related processes and takes the whole group down. Setting
trap_exit changes that for one process: instead of dying, it receives an {'EXIT', Pid, Reason} message and can decide what to do. That one flag is the entire difference between a worker and a supervisor. OCaml has nothing comparable — an exception in a domain surfaces only at Domain.join, so a parent must be waiting at the moment it wants to know.OTP
gen_server Is the Pattern, Extracted
OTP is not a framework bolted on — it is the loop from earlier, factored out, with the fiddly parts done properly.
(* The equivalent is a module holding a state value and
functions that transform it — with the caller
responsible for holding the state and for
concurrency. *)
type state = { total : int }
let bump state = { total = state.total + 1 }
let value state = state.total
let () =
let state = bump (bump (bump { total = 0 })) in
Printf.printf "%d\n" (value state)%% The hand-written loop from the processes section IS a
%% gen_server, minus the parts OTP supplies: synchronous
%% calls with timeouts, code upgrade, tracing, supervision
%% and standard shutdown.
%%
%% handle_call(value, _From, Total) -> {reply, Total, Total};
%% handle_cast(bump, Total) -> {noreply, Total + 1}.
%%
Counter = spawn(fun() ->
Loop = fun Self(Total) ->
receive
bump -> Self(Total + 1);
{value, Caller} -> Caller ! {reply, Total}, Self(Total)
end
end,
Loop(0)
end),
Counter ! bump, Counter ! bump, Counter ! bump,
Counter ! {value, self()},
receive {reply, Total} -> io:format("~p~n", [Total])
after 1000 -> io:format("timed out~n") end.A
gen_server is the receive loop with the generic half extracted: you write handle_call, handle_cast and handle_info, and OTP supplies the loop, synchronous calls with timeouts, orderly shutdown, code upgrade, tracing hooks and supervision integration. The other behaviors — supervisor, gen_statem, application — do the same for their patterns. The closest OCaml analogue is a module with a state type and transformation functions, which is a good design and leaves concurrency, restart policy and lifecycle entirely to you. OTP is the reason Erlang is used in production more than the language is.An Application Is a Unit of the System
OTP's largest unit, and the reason an Erlang system can be started and stopped as a whole rather than as a script.
(* A library is a set of modules. Starting and stopping
it is whatever its API says, and dependencies between
subsystems are managed by hand. *)
let () = print_endline "a library is modules plus a convention"%% An .app file declares a startable, stoppable,
%% supervised unit with its own dependencies:
%%
%% {application, my_app, [
%% {mod, {my_app, []}},
%% {applications, [kernel, stdlib, ssl]}
%% ]}.
%%
%% application:start(my_app) starts its supervision tree
%% and everything it depends on, in order. A release is a
%% set of applications plus the runtime.
io:format("an application is a supervised, startable unit~n").An application bundles modules with a supervision tree, a declared dependency list and start/stop callbacks, so
application:start/1 brings up everything it needs in dependency order and application:stop/1 tears it down cleanly. Applications compose into a release, which adds the runtime itself. The result is that "the system" is a first-class artifact with a defined lifecycle, rather than a binary plus an init script plus a process supervisor supplied by the operating system. OCaml has libraries and executables and leaves the lifecycle entirely to you.What the BEAM Does For You
Preemptive Scheduling
The scheduling property that makes soft real-time possible, and it has no counterpart in any of the other targets on this anchor.
(* A domain runs until it yields or blocks. A tight loop
in one domain does not stop the others, but nothing
guarantees fairness within a domain, and a blocking
call blocks that OS thread. *)
let () =
let busy = Domain.spawn (fun () ->
let total = ref 0 in
for index = 1 to 1000000 do total := !total + index done;
!total)
in
Printf.printf "%d\n" (Domain.join busy)%% The BEAM counts REDUCTIONS and preempts a process after
%% about 2000 of them, whatever it is doing. No process can
%% starve another, and there is no yield point to place.
Busy = spawn(fun() -> ok end),
Parent = self(),
spawn(fun() ->
Total = lists:foldl(fun(N, Sum) -> N + Sum end, 0, lists:seq(1, 100000)),
Parent ! {done, Total}
end),
receive {done, Total} -> io:format("~p~n", [Total]) after 5000 -> ok end,
is_pid(Busy) andalso ok.The BEAM counts reductions — roughly, function calls — and preempts a process after about two thousand, regardless of what it is doing. There is no yield point to remember, no cooperative scheduling to get wrong, and no way for one process to starve another. Combined with a per-process heap and per-process garbage collection, that means no global pause: collecting one process does not stop the system. This is why the platform can promise soft real-time latency, and it is the deepest architectural difference from OCaml, whose domains run until they yield and whose collector, while incremental, is shared.
Replacing Code in a Running System
The feature the language was built for, and the reason the telephone switches it ran had nine-nines availability claims.
(* Deploying new code means starting a new process and
stopping the old one. There is no in-place upgrade. *)
let () = print_endline "deploy = restart the executable"%% Two versions of a module can be loaded at once, and a
%% fully-qualified call switches a running process to the
%% new one at its next loop iteration:
%%
%% loop(State) ->
%% receive
%% upgrade -> ?MODULE:loop(State); %% jumps to the NEW code
%% Message -> loop(handle(Message, State))
%% end.
%%
io:format("deploy = load the new module, no restart~n").The BEAM holds two versions of a module at once — current and old. A local call stays in the version the process is already running; a fully qualified call (
?MODULE:loop(State)) jumps to the newest. So a long-running process picks up new code at a point you choose, carrying its state across, with a code_change callback to migrate that state if its shape changed. No restart, no dropped connections, no draining. OCaml has nothing comparable and neither does any other target on this anchor; a deployment is a new executable. It is worth knowing this exists even if you never use it, because it explains why so much of Erlang looks the way it does.Another Machine Is Another Process
Distribution is in the language rather than in a library, and the same primitives reach across machines.
(* Talking to another machine means choosing a protocol,
a serialization format and a library. *)
let () = print_endline "network = pick HTTP or gRPC, pick a codec, write it"%% Sending to a process on another NODE is the same
%% operator. The runtime handles the connection, the
%% encoding and the monitoring.
%%
%% {counter, 'worker@host'} ! bump,
%% Pid = spawn('worker@host', fun() -> ok end),
%%
%% and a monitor works across the network too, so a
%% machine going away is the same 'DOWN' message a
%% process dying is.
io:format("network = the same ! operator~n").Connect two BEAM nodes and
Pid ! Message works between them unchanged, with the runtime handling connection, term encoding and failure detection. A monitor spans machines, so a node going away arrives as the same 'DOWN' message a local process dying does — one failure model for both. That is a genuinely different starting point from choosing HTTP or gRPC and a codec. The honest caveats: the default distribution protocol assumes a trusted network and a shared cookie, it is a full mesh that does not scale past a few hundred nodes, and "transparent" distribution hides latency and partition behavior that eventually matters.ETS: Shared Storage, Deliberately
The deliberate exception to "nothing is shared", and knowing when to reach for it is a real part of Erlang design.
(* A Hashtbl is shared memory by default: any code with
the handle can read and write it, and concurrent
access needs a mutex. *)
let () =
let table = Hashtbl.create 8 in
Hashtbl.replace table "ada" 36;
match Hashtbl.find_opt table "ada" with
| Some age -> Printf.printf "%d\n" age
| None -> print_endline "absent"%% Nothing is shared between processes — EXCEPT an ETS
%% table, which is explicit, named, and owned by the
%% process that created it.
Table = ets:new(ages, [set]),
ets:insert(Table, {ada, 36}),
case ets:lookup(Table, ada) of
[{ada, Age}] -> io:format("~p~n", [Age]);
[] -> io:format("absent~n")
end.Message passing copies, which is what makes isolation work and what makes a shared cache expensive — every reader would get its own copy. ETS is the escape hatch: an in-memory table living outside any process heap, readable and writable concurrently, with configurable concurrency options. It is explicitly not garbage collected with a process, is owned by its creator and dies with it, and it reintroduces exactly the shared-mutable-state problems the rest of the language removes. The rule of thumb is to use it for caches and lookup tables read far more often than written, and to keep mutable logic in processes.
The System Can Be Inspected While Running
Live introspection is a runtime feature rather than a tool you attach, and it is available in production by default.
(* Introspection means a profiler run, a debugger, or
instrumentation you added in advance. *)
let () = Printf.printf "%d words\n" (Gc.stat ()).Gc.heap_words%% Any process can be asked what it is doing, right now,
%% with no instrumentation added in advance.
Worker = spawn(fun() -> receive stop -> ok after 5000 -> ok end end),
{message_queue_len, QueueLength} =
erlang:process_info(Worker, message_queue_len),
io:format("queue ~p~n", [QueueLength]),
io:format("processes ~p~n", [length(erlang:processes()) > 0]),
Worker ! stop.erlang:process_info/2 reports a process's mailbox length, current function, memory, links and more; erlang:processes() lists every process in the node; sys:get_state/1 extracts a running gen_server's state; and the tracing facilities can watch specific function calls on a live system with per-process filters. None of that needs instrumentation added beforehand, and all of it works over a remote shell against production. OCaml has excellent profiling and a debugger, and they are tools you run against a process rather than questions the runtime answers about itself.The Trade
Effect Handlers and Processes
The comparison this pair exists for: two answers to the same problem, arrived at from opposite ends and forty years apart.
(* OCaml 5's effect handlers: a computation suspends and
a handler decides what to do. This is concurrency
without colored functions, and it is four years old. *)
open Effect
open Effect.Deep
type _ Effect.t += Pause : unit Effect.t
let task () = print_endline "before"; perform Pause; print_endline "after"; 42
let () =
let result =
match_with task ()
{ retc = (fun value -> value)
; exnc = raise
; effc = (fun (type a) (performed : a Effect.t) ->
match performed with
| Pause -> Some (fun (continuation : (a, _) continuation) ->
continue continuation ())
| _ -> None) }
in
Printf.printf "%d\n" result%% Erlang's answer, thirty-five years old: the unit of
%% suspension is a PROCESS, and it blocks in receive. No
%% handler, no continuation, no colored functions either.
Task = fun() ->
io:format("before~n"),
receive resume -> ok end,
io:format("after~n"),
42
end,
Parent = self(),
Worker = spawn(fun() -> Parent ! {result, Task()} end),
Worker ! resume,
receive {result, Value} -> io:format("~p~n", [Value])
after 1000 -> io:format("timed out~n") end.Both columns print the same three lines and neither has colored functions —
task is an ordinary function in both. The mechanisms are opposites. OCaml suspends a computation and hands its continuation to a handler that decides what happens next, which is extremely general: schedulers, generators, backtracking and async I/O are all one feature. Erlang suspends a process, which blocks in receive until a message arrives — far less general, and it comes with isolation, supervision, per-process collection and distribution attached. OCaml has the more powerful primitive; Erlang has thirty-five years of production systems built on the less powerful one.What Is Worth Carrying Home
The last row, and the practical one: which Erlang instincts pay off back in OCaml.
(* Two ideas from Erlang that improve OCaml code and
need no runtime support: separate expected failures
from bugs, and isolate state behind a boundary. *)
type outcome = Ok_value of int | Failed of string
let parse text =
match int_of_string_opt text with
| Some number -> Ok_value number
| None -> Failed "not a number"
let () =
match parse "42" with
| Ok_value number -> Printf.printf "%d\n" number
| Failed reason -> print_endline reason%% The same two ideas, in their native form: an expected
%% failure is a value, an unexpected one crashes the
%% process, and state is unreachable except by message.
Parse = fun(Text) ->
try {ok, list_to_integer(Text)}
catch error:badarg -> {error, not_a_number}
end
end,
case Parse("42") of
{ok, Number} -> io:format("~p~n", [Number]);
{error, Reason} -> io:format("~p~n", [Reason])
end.Two transfer directly and need no runtime support. Separate expected failures from bugs — a
result for what a caller should handle, an exception for a broken invariant, and never the reverse; conflating them is what produces both over-defensive code and swallowed errors. Put state behind a boundary that serializes access — in OCaml that is a module with an abstract type and functions that transform it, which gets you the same "no unsynchronized reach-in" property by discipline where Erlang gets it by construction. What does not transfer is "let it crash", which is not a coding style but a property of a runtime with cheap isolated processes and supervision; adopting it without them is just unhandled exceptions.When Erlang Is the Wrong Answer
The honest limit, stated plainly: the runtime that makes everything else possible is not fast at this.
(* OCaml compiles to native code and is frequently
within a small factor of C on numeric work. *)
let () =
let total = ref 0.0 in
for index = 1 to 10000 do
total := !total +. float_of_int index *. 1.5
done;
Printf.printf "%.2f\n" !total%% Both columns do the SAME ten thousand iterations, so
%% the numbers match. What differs is how long they take:
%% the BEAM is optimized for scheduling fairness, not for
%% arithmetic, and on this loop it is far behind.
Total = lists:foldl(fun(Index, Sum) -> Sum + Index * 1.5 end,
0.0, lists:seq(1, 10000)),
io:format("~.2f~n", [Total]).The BEAM is built for scheduling millions of processes fairly and preempting them predictably, and those goals cost raw throughput. Numeric and CPU-bound work is substantially slower than OCaml's native code — often by an order of magnitude — and although the two columns above do the same ten thousand iterations and print the same total, the time taken is not close. The standard answer is a NIF (a C function loaded into the VM), which reintroduces every risk Erlang exists to remove: a NIF that crashes takes the whole node with it, and one that blocks too long breaks the scheduler's fairness guarantee. If the hard part of the problem is computation rather than coordination, this is the wrong runtime.