Reading Erlang
Hello, World
IO.puts("Hello, World!") io:format("Hello, World!~n"). Modules are lowercase and called with a colon (
io:format ≈ IO.puts); output goes through format strings, where ~n is the newline. The trailing period ends the expression sequence — Erlang’s most famous piece of punctuation.Commas, semicolons, periods
numbers = [1, 2, 3]
total = Enum.sum(numbers)
IO.puts(total) Numbers = [1, 2, 3],
Total = lists:sum(Numbers),
io:format("~p~n", [Total]). The grammar Elixir redesigned away, inherited from Prolog: commas separate expressions in a sequence, semicolons separate clauses (visible in
case and multi-clause funs below), and a period ends the whole form. The content of this example is otherwise identical line-for-line.Capitalized variables, bare atoms
status = :active
retries = 3
IO.inspect({status, retries}) Status = active,
Retries = 3,
io:format("~p~n", [{Status, Retries}]). The visual convention inverts: variables are Capitalized, and atoms are bare lowercase words — no colon sigil. Elixir needed the
:atom sigil precisely because it gave lowercase names to variables instead. ~p pretty-prints any term, playing IO.inspect.Single Assignment
No rebinding — ever
count = 1
count = count + 1 # rebinding is ordinary Elixir
IO.puts(count) Count = 1,
NextCount = Count + 1, % Count = Count + 1 would crash: no match
io:format("~p~n", [NextCount]). Erlang variables are single-assignment for real: once bound,
= against a different value is a badmatch crash. Elixir’s rebinding is compiler sugar over this same VM — Erlang makes you name every intermediate value, which is why real Erlang code is full of State1/NewState chains.No pin operator — everything is pinned
expected = :ok
{^expected, value} = {:ok, 42} # ^ opts INTO match-against-value
IO.inspect(value) Expected = ok,
{Expected, Value} = {ok, 42}, % a bound variable always matches its value
io:format("~p~n", [Value]). The
^ pin does not exist because it is never needed: a bound Erlang variable in a pattern always means "match my current value" — the behavior Elixir makes you opt into. Only unbound variables bind.Strings: Two Kinds
"hello" is a list of integers
text = "hello" # a UTF-8 binary
IO.inspect(is_binary(text))
IO.inspect(~c"hello") # the charlist, behind a sigil Text = "hello", % a LIST of integers
Binary = <<"hello">>, % what Elixir calls a string
io:format("~w~n", [Text]), % ~w shows the raw term
io:format("~s ~s~n", [Text, Binary]). The single biggest trap on this page: double quotes make a charlist in Erlang —
[104,101,108,108,111] — while Elixir’s string is the binary <<"hello">>. Older Erlang APIs traffic in charlists, newer ones in binaries; Elixir’s ~c sigil exists exactly for this boundary.No interpolation — format directives
name = "Ada"
IO.puts("hello #{name}")
IO.inspect(%{name: name}, label: "debug") Name = <<"Ada">>,
io:format("hello ~s~n", [Name]),
io:format("debug: ~p~n", [#{name => Name}]). There is no
#{} interpolation — io:format takes a format string and an argument list. The directives to know: ~s (string), ~p (pretty-print any term — the workhorse), ~w (raw term), ~n (newline).<> is binary construction
name = "Ada"
greeting = "hello " <> name
IO.puts(greeting)
IO.inspect(byte_size(greeting)) Name = <<"Ada">>,
Greeting = <<"hello ", Name/binary>>,
io:format("~s~n", [Greeting]),
io:format("~p~n", [byte_size(Greeting)]). Elixir’s
<> concatenation is sugar over Erlang’s binary-construction syntax: <<"hello ", Name/binary>> splices one binary into another, with /binary declaring the segment type. byte_size is the same auto-imported BIF in both.Maps, Tuples & Records
Maps — the same maps
person = %{name: "Ada", age: 36}
%{name: name} = person
IO.puts(name)
IO.inspect(%{person | age: 37}) Person = #{name => "Ada", age => 36},
#{name := Name} = Person,
io:format("~s~n", [Name]),
io:format("~p~n", [Person#{age := 37}]). Elixir maps are Erlang maps — one VM type.
%{key => value} is #{key => value}; := in a pattern or update means "this key must already exist" (the update behavior of Elixir’s %{map | key: value}); and Person#{…} is the update syntax.{ok, _} — the convention came from here
result = {:ok, 42}
case result do
{:ok, value} -> IO.puts("got #{value}")
{:error, reason} -> IO.inspect(reason)
end Result = {ok, 42},
case Result of
{ok, Value} -> io:format("got ~p~n", [Value]);
{error, Reason} -> io:format("~p~n", [Reason])
end. The
{:ok, _}/{:error, _} convention is Erlang’s, inherited whole — just drop the colons. Note case … of and the semicolon between clauses; the final clause takes no separator.Structs → records
defmodule Person do
defstruct name: "", age: 0
end
person = %Person{name: "Ada", age: 36}
IO.puts(person.name) % A record is a compile-time construct — under the hood it is a
% plain tagged tuple {person, "Ada", 36}, with the -record
% attribute giving names to positions:
-record(person, {name = "", age = 0}).
birthday() ->
Person = #person{name = "Ada", age = 36},
io:format("~s~n", [Person#person.name]). Erlang’s record is what Elixir’s struct replaced: a preprocessor-era construct that names tuple positions at compile time (a struct, by contrast, is a real map at runtime). Records require module context, so this cell is display-only on this page’s shell evaluator. Modern Erlang increasingly just uses maps.
Functions
defmodule → -module
defmodule Geometry do
def area(width, height), do: width * height
end
IO.puts(Geometry.area(3, 4)) -module(geometry).
-export([area/2]).
area(Width, Height) -> Width * Height. A module file opens with attributes:
-module (which must match the filename) and -export, the public-function list — Erlang’s version of the def/defp split. Function heads use -> and end with a period. Module definitions cannot run at the shell-expression level this page evaluates, so this cell is display-only.fn → fun (and no dot to call)
double = fn number -> number * 2 end
IO.puts(double.(21))
classify = fn
number when number < 0 -> :negative
0 -> :zero
_ -> :positive
end
IO.inspect(classify.(-5)) Double = fun(Number) -> Number * 2 end,
io:format("~p~n", [Double(21)]),
Classify = fun(Number) when Number < 0 -> negative;
(0) -> zero;
(_) -> positive
end,
io:format("~p~n", [Classify(-5)]). fun … end is fn … end, with semicolons between clauses — and calling a variable-held fun needs no dot: Double(21), not double.(21). (Elixir’s dot exists because its variables and named functions share a flat namespace; Erlang keeps them apart.)&Module.function/1 → fun module:function/1
IO.inspect(Enum.map([1, 2, 3], &(&1 * 10)))
IO.inspect(Enum.map([-1, -2, -3], &abs/1)) io:format("~p~n", [lists:map(fun(Number) -> Number * 10 end, [1, 2, 3])]),
io:format("~p~n", [lists:map(fun erlang:abs/1, [-1, -2, -3])]). Elixir’s capture operator came straight from Erlang’s
fun module:function/arity. The shorthand &(&1 * 10) has no Erlang equivalent — a full fun is always written out.lists ≈ Enum (Arguments Flipped)
Enum → lists, arguments flipped
numbers = [1, 2, 3, 4]
IO.inspect(Enum.filter(numbers, fn number -> rem(number, 2) == 0 end))
IO.inspect(Enum.map(numbers, fn number -> number * number end)) Numbers = [1, 2, 3, 4],
io:format("~p~n", [lists:filter(fun(Number) -> Number rem 2 =:= 0 end, Numbers)]),
io:format("~p~n", [lists:map(fun(Number) -> Number * Number end, Numbers)]). The
lists module is Enum for lists — but the argument order flips: the fun comes FIRST, the list last. Elixir reversed it so the collection could ride the pipe; Erlang has no pipe, so nested calls or intermediate variables do that job. rem is an infix operator here, not a function.reduce → foldl
total = Enum.reduce([1, 2, 3, 4], 0, fn number, sum -> sum + number end)
IO.inspect(total) Total = lists:foldl(fun(Number, Sum) -> Sum + Number end, 0, [1, 2, 3, 4]),
io:format("~p~n", [Total]). Enum.reduce/3 is lists:foldl/3 (fold from the left; foldr exists too), with the order Fun, Accumulator, List. The callback’s parameters match Elixir’s: element first, accumulator second.for → [Expr || Gen, Filter]
squares = for number <- 1..5, rem(number, 2) == 1, do: number * number
IO.inspect(squares) Squares = [Number * Number || Number <- lists:seq(1, 5), Number rem 2 =:= 1],
io:format("~p~n", [Squares]). Elixir’s
for is Erlang’s list comprehension with the parts rearranged — [Expression || Generator, Filter]. One real gap: Erlang has no range type, so 1..5 becomes lists:seq(1, 5).Control Flow
case — nearly identical
number = 15
description = case number do
0 -> "zero"
value when rem(value, 5) == 0 -> "multiple of five"
_ -> "ordinary"
end
IO.puts(description) Number = 15,
Description = case Number of
0 -> "zero";
Value when Value rem 5 =:= 0 -> "multiple of five";
_ -> "ordinary"
end,
io:format("~s~n", [Description]). The construct Elixir kept nearly unchanged:
case … of with pattern clauses, when guards, and a value in every branch. Only the punctuation differs — semicolons between clauses, no do.Erlang’s if IS Elixir’s cond
temperature = 30
description = cond do
temperature > 25 -> "hot"
temperature > 15 -> "mild"
true -> "cold"
end
IO.puts(description) Temperature = 30,
Description = if
Temperature > 25 -> "hot";
Temperature > 15 -> "mild";
true -> "cold"
end,
io:format("~s~n", [Description]). Erlang’s
if is a guard sequence with a true catch-all — exactly Elixir’s cond, which is why Elixir’s cond looks the way it does. Two constraints to remember: the conditions must be guard expressions (no arbitrary function calls), and there is no with, unless, or two-branch if/else sugar.Operators & Equality
=== → =:=
IO.inspect(1 == 1.0) # true — numeric comparison
IO.inspect(1 === 1.0) # false — exact equality
IO.inspect(1 != 2)
IO.inspect(1 !== 1.0) io:format("~p~n", [1 == 1.0]), % true — numeric comparison
io:format("~p~n", [1 =:= 1.0]), % false — exact term equality
io:format("~p~n", [1 /= 2]),
io:format("~p~n", [1 =/= 1.0]). Same two-tier equality, different spellings:
=== is =:=, !== is =/=, and != is /=. The semantics carried over to Elixir unchanged, so nothing new to learn — only new punctuation to read.and/or → andalso/orelse
IO.inspect(true and false)
IO.inspect(1 < 2 or 3 < 2)
IO.inspect(nil || "default") # || accepts any value (truthiness) io:format("~p~n", [true andalso false]), % short-circuit
io:format("~p~n", [(1 < 2) orelse (3 < 2)]),
io:format("~p~n", [true and false]). % and/or evaluate BOTH sides The short-circuit pair is
andalso/orelse; Erlang’s bare and/or evaluate both operands. Nothing corresponds to ||/&& because Erlang has no truthiness at all — only true and false are booleans, and there is no nil (the bare atom undefined is the convention).Error Handling
rescue → catch Class:Reason
result = try do
raise "boom"
rescue
error in RuntimeError -> "rescued: #{error.message}"
end
IO.puts(result) Result = try
error(boom)
catch
error:boom -> "caught error:boom"
end,
io:format("~s~n", [Result]). Erlang exposes the raw machinery Elixir’s
rescue sugars over: three error classes — throw:, error:, exit: — caught by Class:Reason patterns (a third position, Class:Reason:Stacktrace, binds the trace). Elixir’s raise is an error carrying an exception struct.No with — the staircase
parse = fn text ->
try do
{:ok, String.to_integer(text)}
rescue
ArgumentError -> {:error, :not_a_number}
end
end
with {:ok, value} <- parse.("42") do
IO.puts(value)
end Parse = fun(Text) ->
try {ok, list_to_integer(Text)}
catch error:badarg -> {error, not_a_number}
end
end,
case Parse("42") of
{ok, Value} -> io:format("~p~n", [Value]);
{error, Reason} -> io:format("~p~n", [Reason])
end. Erlang has no
with: chaining fallible steps means nested case expressions — the "staircase" that with was invented to flatten. (Erlang/OTP 25 finally added its own answer, the maybe expression, but the staircase still dominates existing code.)Processes — Shared Ground
send → the ! operator
parent = self()
spawn(fn -> send(parent, {:result, 6 * 7}) end)
receive do
{:result, value} -> IO.puts("got #{value}")
end Parent = self(),
spawn(fun() -> Parent ! {result, 6 * 7} end),
receive
{result, Value} -> io:format("got ~p~n", [Value])
end. Identical model, same VM, same guarantees — this is the shared ground everything else stands on. The only translation: Elixir’s
send(pid, message) is Erlang’s Pid ! Message operator, and receive drops the do.Process.monitor → monitor/2
worker = spawn(fn ->
receive do
:crash -> exit(:boom)
end
end)
reference = Process.monitor(worker)
send(worker, :crash)
receive do
{:DOWN, ^reference, :process, _pid, reason} -> IO.inspect(reason)
end Worker = spawn(fun() ->
receive
crash -> exit(boom)
end
end),
Reference = monitor(process, Worker),
Worker ! crash,
receive
{'DOWN', Reference, process, _Pid, Reason} -> io:format("~p~n", [Reason])
end. Process.monitor/1 is a thin wrapper over monitor(process, Pid), and the DOWN message has the same five-tuple shape. One reading note: 'DOWN' needs single quotes in Erlang, because an unquoted capitalized word would be a variable. Links, trap_exit, and everything OTP builds from them translate the same way.Macros & OTP Contracts
defmacro → -define (a preprocessor)
defmodule Answers do
defmacro double(expression) do
quote do
unquote(expression) * 2
end
end
end
# Callers `require Answers` and get an AST transformation,
# with the full language available at compile time. -define(DOUBLE(Expression), ((Expression) * 2)).
usage() ->
io:format("~p~n", [?DOUBLE(21)]). Erlang’s
-define is C-style textual substitution — ?MACRO pastes tokens, with all the parenthesization hazards that implies. Elixir macros are a different species: quote/unquote AST transformation with the whole language available, the machinery Elixir itself is built from (if, |>, and def are macros). Both cells need module context, so this row is display-only.use GenServer → gen_server callbacks
defmodule Counter do
use GenServer
def init(initial), do: {:ok, initial}
def handle_call(:value, _from, state) do
{:reply, state, state}
end
end
{:ok, process} = GenServer.start_link(Counter, 41)
IO.inspect(GenServer.call(process, :value)) -module(counter).
-behavior(gen_server).
-export([init/1, handle_call/3]).
init(Initial) -> {ok, Initial}.
handle_call(value, _From, State) ->
{reply, State, State}. OTP is the shared inheritance:
GenServer is gen_server, and the callbacks have the same names and return shapes because they are the same contract, declared with the attribute shown (whose canonical OTP spelling ends in -our; the compiler accepts the American spelling too). Elixir’s use GenServer additionally injects default callback implementations — Erlang modules write out each one. Module definitions are display-only on this page’s shell evaluator.Stdlib & Tooling
You already call this stdlib
IO.inspect(:lists.reverse([1, 2, 3]))
IO.inspect(:maps.keys(%{name: "Ada"}))
IO.inspect(:erlang.tuple_size({:ok, 1, 2})) io:format("~p~n", [lists:reverse([1, 2, 3])]),
io:format("~p~n", [maps:keys(#{name => <<"Ada">>})]),
io:format("~p~n", [tuple_size({ok, 1, 2})]). Every
:module.function call in Elixir is a plain Erlang call — the colon-atom prefix is the entire interop story, with zero overhead. Reading Erlang’s stdlib documentation pays off immediately in Elixir; the reverse direction works too (an Elixir module is the atom 'Elixir.Enum' from Erlang’s side).iex → erl
# $ iex
# iex(1)> greeting = "hello"
# "hello"
# iex(2)> String.upcase(greeting)
# "HELLO"
# iex(3)> h String.upcase # docs in the shell % $ erl
% 1> Greeting = "hello". % note the PERIOD to evaluate
% "hello"
% 2> string:uppercase(Greeting).
% "HELLO"
% 3> f(Greeting). % f/1 "forgets" a binding
% 4> q(). % quit — also a function call The Erlang shell wants a period to evaluate anything — the classic first-day stumble. Since variables cannot rebind,
f(Variable) explicitly forgets bindings during experimentation, and even quitting is a function call, q(). Both cells are terminal transcripts, shown display-only.mix → rebar3
# mix.exs
defp deps do
[
{:jason, "~> 1.4"},
{:telemetry, "~> 1.2"}
]
end
# $ mix deps.get && mix test %% rebar.config
{deps, [
{jsx, "3.1.0"},
{telemetry, "1.2.1"}
]}.
%% $ rebar3 compile && rebar3 eunit rebar3 is the mix of the Erlang world — rebar.config is Erlang terms rather than Elixir code, and both pull from the same package registry: Hex serves both communities. Both cells are configuration fragments, shown display-only.