PONY λ M2 Modula-2

Erlang.CodeCompared.To/Elixir

An interactive executable cheatsheet comparing Erlang and Elixir

Erlang/OTP 26 Elixir 1.17
Output & Running
Hello, World
io:format("Hello, World!~n").
IO.puts("Hello, World!")
Both run on the BEAM. Erlang prints with io:format/1 and the ~n format directive for the newline; Elixir's IO.puts/1 appends the newline for you. Note that Elixir statements are separated by newlines, not terminated by . — the trailing period is a piece of Erlang punctuation you get to leave behind.
Inspecting a value
Value = {ok, [1, 2, 3]}, io:format("~p~n", [Value]).
value = {:ok, [1, 2, 3]} IO.inspect(value)
Erlang's ~p ("pretty print") directive renders any term for humans; the value to print is passed in the argument list. Elixir's IO.inspect/1 does the same and — handily — returns its argument, so you can drop it into the middle of a pipeline to peek at a value without breaking the chain.
Variables & Matching
Variables are matches
X = 40, Y = 2, io:format("~p~n", [X + Y]).
x = 40 y = 2 IO.puts(x + y)
In Erlang variables are Capitalized and bind exactly once — = is a match, not assignment. Elixir keeps the match semantics but uses lowercase names and, crucially, allows rebinding: writing x = 40 and later x = 99 is fine. It does not mutate the old value (everything is still immutable) — it just points the name at a new one.
Rebinding and the pin operator
% Erlang cannot rebind — this would be a "no match" error: X = 1, % X = 2, would fail io:format("~p~n", [X]).
x = 1 x = 2 # rebinds freely ^x = 2 # ^ matches against x's value (like Erlang's X) IO.puts(x)
Because Elixir lets you rebind, it needs a way to say "match against this variable's current value" — that is the pin operator ^. So ^x = 2 in Elixir behaves like a bare X = 2 in Erlang once X is bound: it asserts equality rather than rebinding. Reach for ^ whenever you want Erlang's single-assignment behaviour inside a pattern.
Destructuring a tagged tuple
Result = {ok, 42}, {ok, Value} = Result, io:format("~p~n", [Value]).
result = {:ok, 42} {:ok, value} = result IO.puts(value)
The {ok, Value} tagged-tuple idiom is identical on both sides — matching against {ok, Value} both asserts the shape and extracts the payload. Erlang's bare atom ok simply becomes Elixir's :ok with a leading colon.
Atoms, Booleans & nil
Atoms wear a colon
Status = ok, io:format("~p ~p~n", [Status, is_atom(Status)]).
status = :ok IO.puts("#{status} #{is_atom(status)}")
Same concept, different spelling: an Erlang atom is a bare lowercase word (ok, error); an Elixir atom carries a leading colon (:ok). The colon exists precisely because Elixir's lowercase identifiers are variables, so atoms need a marker to tell them apart.
Booleans are atoms; module names are atoms
io:format("~p ~p~n", [is_atom(true), is_atom(lists)]).
IO.puts("#{is_atom(true)} #{is_atom(List)}")
In both languages true and false are just atoms, and so are module names — in Erlang lists is an atom, and in Elixir List is sugar for the atom :"Elixir.List". This is why you can pass a module around as a value and call it dynamically; the "everything is a term" model is inherited straight from Erlang.
Numbers
Integer division and remainder
io:format("~p ~p ~p~n", [17 div 5, 17 rem 5, 17 / 5]).
IO.puts("#{div(17, 5)} #{rem(17, 5)} #{17 / 5}")
These map almost one-to-one: Erlang's div and rem operators become Elixir's div/2 and rem/2 functions, and in both languages / always produces a float (3.4). Both also give you arbitrary-precision integers for free.
Strings vs Charlists
A double-quoted string is different
% In Erlang "abc" is a LIST of character codes: S = "abc", io:format("~w~n", [S]). % ~w prints the raw list: [97,98,99]
# In Elixir "abc" is a UTF-8 binary (a real string): string = "abc" IO.puts(is_binary(string)) # true IO.inspect(string) # "abc"
This is the biggest day-one surprise. In Erlang, "abc" is a charlist — a list of integer code points — which is why the ~w ("write the raw term") directive shows [97,98,99]. (The everyday ~p directive hides this: it heuristically prints printable char lists back as "abc".) In Elixir, "abc" is a binary, a proper UTF-8 string. Elixir's charlists exist too, written ~c"abc", but are reserved for the rare Erlang API that demands them.
Building and formatting strings
Name = "Ada", Greeting = lists:flatten(io_lib:format("Hello, ~s!", [Name])), io:format("~s~n", [Greeting]).
name = "Ada" greeting = "Hello, #{name}!" IO.puts(greeting)
Where Erlang reaches for io_lib:format/2 (and often lists:flatten to tidy the resulting iolist), Elixir has Ruby-style interpolation baked into the string literal: "Hello, #{name}!". Concatenation is <> in Elixir (the binary-concatenation operator), versus ++ on Erlang charlists.
Lists & Tuples
Lists and the cons pattern
List = [1, 2, 3], [Head | Tail] = List, io:format("~p and ~p~n", [Head, Tail]).
list = [1, 2, 3] [head | tail] = list IO.puts("#{head} and #{inspect(tail)}")
Identical linked-list model, identical cons syntax: [Head | Tail] in Erlang is [head | tail] in Elixir. Both are singly-linked lists where prepending with [x | list] is cheap and indexing into the middle is not — the instincts you built in Erlang carry over exactly.
The lists module becomes Enum
Numbers = [1, 2, 3, 4, 5], Evens = lists:filter(fun(N) -> N rem 2 == 0 end, Numbers), Total = lists:sum(Evens), io:format("~p~n", [Total]).
total = [1, 2, 3, 4, 5] |> Enum.filter(fn number -> rem(number, 2) == 0 end) |> Enum.sum() IO.puts(total)
Erlang's lists module takes the list as the last argument (lists:filter(Fun, List)); Elixir's Enum module takes the collection first, which is exactly what lets the pipe |> thread a value through a chain of transformations. The functions themselves — filter, map, foldlreduce — are the same tools you already know.
Tuples
Point = {3, 4}, X = element(1, Point), Y = element(2, Point), io:format("~p~n", [X + Y]).
point = {3, 4} x = elem(point, 0) y = elem(point, 1) IO.puts(x + y)
Tuples are fixed-size, contiguous terms in both languages. The one thing to watch: Erlang's element/2 is 1-indexed (element(1, Point)), while Elixir's elem/2 is 0-indexed (elem(point, 0)). In practice you rarely index tuples at all — you pattern-match them.
Maps, Records & Structs
Maps
Person = #{name => "Ada", born => 1815}, Name = maps:get(name, Person), io:format("~s~n", [Name]).
person = %{name: "Ada", born: 1815} IO.puts(person.name) # dot access on atom keys IO.puts(person[:name]) # also works
Erlang maps use #{key => value} and maps:get/2; Elixir maps use %{key => value}, and when every key is an atom you get the %{name: "Ada"} shorthand plus person.name dot access. The runtime representation is the same BEAM map underneath.
Updating a map
Person = #{name => "Ada", age => 36}, Older = Person#{age := 37}, io:format("~p~n", [maps:get(age, Older)]).
person = %{name: "Ada", age: 36} older = %{person | age: 37} IO.puts(older.age)
Both languages update a map by producing a new one, and both distinguish "update an existing key" from "add a key". Erlang's Map#{key := Value} (with :=) requires the key to exist; Elixir's %{map | key: value} does the same and raises if the key is missing — a small guard against typos.
Records become structs
%% Erlang records are a compile-time construct (-record), %% so this is display-only here: -record(user, {name, admin = false}). new_admin(Name) -> #user{name = Name, admin = true}.
defmodule User do defstruct name: nil, admin: false end user = %User{name: "Ada", admin: true} IO.puts(user.name)
An Erlang -record is compile-time sugar over a tagged tuple, defined at the module level (so it cannot run in this shell-based sandbox — it is shown for contrast). Elixir's defstruct is its spiritual successor but backed by a map: you get named fields, defaults, dot access, and struct pattern matching (%User{admin: true}), all without the header-file ceremony records are famous for.
Comprehensions & Enum
List comprehensions
Squares = [N * N || N <- lists:seq(1, 5), N rem 2 == 0], io:format("~p~n", [Squares]).
squares = for number <- 1..5, rem(number, 2) == 0, do: number * number IO.inspect(squares)
Comprehensions are one of the closest matches between the two languages. Erlang's [Expr || Generator, Filter] becomes Elixir's for generator, filter, do: expr — same generator-and-filter idea, just with keywords instead of the || and <- punctuation. Elixir's 1..5 range replaces lists:seq(1, 5).
foldl becomes reduce
Sum = lists:foldl(fun(N, Acc) -> Acc + N end, 0, [1, 2, 3, 4]), io:format("~p~n", [Sum]).
sum = Enum.reduce([1, 2, 3, 4], 0, fn number, acc -> acc + number end) IO.puts(sum)
Erlang's lists:foldl/3 is Elixir's Enum.reduce/3. The accumulator threads through the same way; the argument order of the function even matches (fun(Element, Acc)fn element, acc). Elixir also offers Enum.sum/1 and dozens of other specialised reducers so you rarely hand-roll the fold.
Pattern Matching & Flow
case
Result = {ok, 200}, case Result of {ok, Code} -> io:format("ok: ~p~n", [Code]); {error, Code} -> io:format("err: ~p~n", [Code]) end.
result = {:ok, 200} case result do {:ok, code} -> IO.puts("ok: #{code}") {:error, code} -> IO.puts("err: #{code}") end
Nearly identical. The differences are cosmetic: Elixir uses -> and a do/end block where Erlang uses -> with clauses separated by ; and terminated by end, and Elixir drops the trailing period. The matching semantics — first clause whose pattern fits wins — are exactly the same.
Guards
Sign = fun (N) when N > 0 -> positive; (N) when N < 0 -> negative; (_) -> zero end, io:format("~p~n", [Sign(-3)]).
sign = fn n when n > 0 -> :positive n when n < 0 -> :negative _ -> :zero end IO.puts(sign.(-3))
Guards (when) work the same in both languages and are restricted to the same small set of side-effect-free tests. The example uses a multi-clause anonymous function on both sides; note Elixir invokes an anonymous function with a dot — sign.(-3) — the one syntactic reminder that it is a value, not a named function.
Nested cases become with
%% Erlang chains matches with nested case expressions: case {ok, 10} of {ok, A} -> case {ok, 32} of {ok, B} -> io:format("~p~n", [A + B]); _ -> io:format("error~n") end; _ -> io:format("error~n") end.
result = with {:ok, a} <- {:ok, 10}, {:ok, b} <- {:ok, 32} do a + b else _ -> :error end IO.inspect(result)
Erlang's answer to a sequence of dependent matches is nested case expressions, which drift rightward fast. Elixir's with flattens exactly that pattern: each <- clause must match to continue, and the first failure falls through to else. It is the single most welcome control-flow addition for an Erlang developer.
Functions
Anonymous functions
Double = fun(N) -> N * 2 end, io:format("~p~n", [Double(21)]).
double = fn number -> number * 2 end IO.puts(double.(21))
Erlang's fun(N) -> ... end is Elixir's fn n -> ... end, and both are called with a dot in Elixir (double.(21)) versus a plain application in Erlang (Double(21)). The dot is Elixir's visible distinction between calling a named function and calling a value that holds one.
Function references and the capture operator
Doubled = lists:map(fun erlang:abs/1, [-1, -2, 3]), io:format("~p~n", [Doubled]).
doubled = Enum.map([-1, -2, 3], &abs/1) IO.inspect(doubled)
Erlang refers to an existing function as a value with fun Module:Name/Arity. Elixir's capture operator & is the terser equivalent — &abs/1 captures the named function, and &(&1 * 2) builds a new one where &1 is the first argument. Both express the same "function as a first-class value" idea.
A recursive function
Factorial = fun Fact(0) -> 1; Fact(N) -> N * Fact(N - 1) end, io:format("~p~n", [Factorial(5)]).
defmodule Math do def factorial(0), do: 1 def factorial(n), do: n * factorial(n - 1) end IO.puts(Math.factorial(5))
At the shell an Erlang recursive function is a named fun (fun Fact(...) -> ... end, clauses separated by ;). In a real module you would define fact/1 with two clauses — which is exactly what the Elixir side shows: one def per pattern, base case first. Same multi-clause, pattern-driven recursion, expressed as named functions in a module.
Modules & Namespaces
Defining a module
%% Erlang: one module per file, explicit export list. -module(calculator). -export([add/2, square/1]). add(A, B) -> A + B. square(N) -> N * N.
defmodule Calculator do def add(a, b), do: a + b def square(n), do: n * n end IO.puts(Calculator.add(2, 3))
An Erlang module is a whole file headed by -module and an -export list (so it is display-only in this expression sandbox). Elixir's defmodule can be defined inline and needs no export list — def is public and defp is private. Module names are dotted and hierarchical (MyApp.Calculator) rather than flat atoms.
Calling module functions
Length = string:length("hello"), Upper = string:uppercase("abc"), io:format("~p ~s~n", [Length, Upper]).
length = String.length("hello") upper = String.upcase("abc") IO.puts("#{length} #{upper}")
Calling into the standard library reads the same on both sides — Module:function(Args) in Erlang, Module.function(args) in Elixir. Elixir's String module is largely a friendlier, binary-oriented layer over Erlang's string and unicode modules, and you can always call the Erlang ones directly (:string.length(...)) when you want to.
Error Handling
Errors as return values
Fetch = fun(Map, Key) -> case maps:find(Key, Map) of {ok, Value} -> {ok, Value}; error -> {error, missing} end end, io:format("~p~n", [Fetch(#{a => 1}, b)]).
fetch = fn map, key -> case Map.fetch(map, key) do {:ok, value} -> {:ok, value} :error -> {:error, :missing} end end IO.inspect(fetch.(%{a: 1}, :b))
The {ok, Value} / {error, Reason} convention is a shared cultural bedrock — both maps:find/2 and Map.fetch/2 return exactly these, and callers match on them instead of raising. Elixir formalises the companion convention too: a foo! function is the variant that raises, while bang-less foo returns a tagged tuple.
try / catch
try throw(boom) of _ -> ok catch throw:Reason -> io:format("caught: ~p~n", [Reason]) end.
try do throw(:boom) catch :throw, reason -> IO.puts("caught: #{reason}") end
Erlang and Elixir share the same three-way error model — throw, error, and exit — and both catch on the class and the value (throw:Reason:throw, reason). In both communities this is the rarely-used path: the idiomatic approach is tagged tuples for expected failures and letting a process crash (to be restarted by its supervisor) for the unexpected ones.
Processes & The Actor Model
Processes talk by messages
Parent = self(), spawn(fun() -> Parent ! {result, 6 * 7} end), receive {result, Value} -> io:format("worker computed ~p~n", [Value]) end.
parent = self() spawn(fn -> send(parent, {:result, 6 * 7}) end) receive do {:result, value} -> IO.puts("worker computed #{value}") end
This is the machinery both languages exist to provide, and it is the same machinery — literally the same BEAM primitives. Erlang's Pid ! Message send operator becomes Elixir's send(pid, message), and spawn, self(), and receive are identical. Anything you know about processes, mailboxes, links, and monitors transfers with zero relearning.
A stateful process is a recursive loop
Loop = fun Loop(Count) -> receive {bump, _} -> Loop(Count + 1); {value, From} -> From ! Count, Loop(Count) end end, Counter = spawn(fun() -> Loop(0) end), Counter ! {bump, none}, Counter ! {bump, none}, Counter ! {value, self()}, receive Total -> io:format("~p~n", [Total]) end.
defmodule Counter do def loop(count) do receive do {:bump, _} -> loop(count + 1) {:value, from} -> send(from, count); loop(count) end end end counter = spawn(fn -> Counter.loop(0) end) send(counter, {:bump, nil}) send(counter, {:bump, nil}) send(counter, {:value, self()}) receive do total -> IO.puts(total) end
The "state is an argument to a tail-recursive receive loop" pattern is pure Erlang, and Elixir inherits it unchanged — the two examples are line-for-line equivalent. In real code both languages wrap this in OTP's gen_server/GenServer, which is the same behaviour under both names.
Behaviours & Protocols
Behaviours carry across unchanged
%% Erlang behaviours (gen_server, supervisor, …) are module-level %% callback contracts — display-only here: -module(my_worker). -behaviour(gen_server). -export([init/1, handle_call/3]). init(Args) -> {ok, Args}. handle_call(_Request, _From, State) -> {reply, ok, State}.
defmodule MyWorker do use GenServer @impl true def init(args), do: {:ok, args} @impl true def handle_call(_request, _from, state), do: {:reply, :ok, state} end
OTP behaviours are the same contract in both languages — gen_server is GenServer, with identical callbacks and return shapes. Elixir's use GenServer injects sensible defaults and @impl true documents which callbacks you are implementing, but the underlying behaviour is Erlang's. (Module-level, so display-only here.)
Protocols: polymorphism Erlang lacks
%% Erlang has no data-dispatched polymorphism; you branch by hand: Describe = fun(Value) -> case Value of V when is_integer(V) -> io_lib:format("int ~p", [V]); V when is_binary(V) -> io_lib:format("str ~s", [V]); _ -> "other" end end, io:format("~s~n", [Describe(42)]).
defmodule Money do defstruct cents: 0 end defimpl String.Chars, for: Money do def to_string(money) do "$#{:erlang.float_to_binary(money.cents / 100, decimals: 2)}" end end IO.puts(%Money{cents: 1599})
Erlang dispatches on data only by hand-written is_* guards or tagged tuples. Elixir adds protocolsdefimpl String.Chars, for: Money teaches IO.puts and interpolation how to render a Money value, the way defining a method would in an OO language. It is genuinely new capability on top of the BEAM, not just nicer syntax.
Tooling & Ecosystem
The pipe operator
%% Erlang has no pipe — you nest calls inside-out: Result = lists:sum( lists:map(fun(N) -> N * 10 end, lists:filter(fun(N) -> N rem 2 == 0 end, [1, 2, 3, 4, 5]))), io:format("~p~n", [Result]).
result = [1, 2, 3, 4, 5] |> Enum.filter(fn n -> rem(n, 2) == 0 end) |> Enum.map(fn n -> n * 10 end) |> Enum.sum() IO.puts(result)
Erlang makes you nest calls inside-out, reading right-to-left and inner-to-outer. Elixir's pipe |> feeds each result as the first argument of the next call, so the code reads top-to-bottom in the order it executes. For a pipeline of lists/Enum transformations this is the single biggest readability win Elixir offers.
rebar3 becomes Mix
%% Build/deps/test in Erlang: rebar3 + a rebar.config file. %% $ rebar3 new app my_app %% $ rebar3 compile %% $ rebar3 eunit {deps, [{jsx, "3.1.0"}]}.
# Build/deps/test in Elixir: one tool, Mix. # $ mix new my_app # $ mix deps.get # $ mix test defp deps do [{:jason, "~> 1.4"}] end
Elixir ships one blessed build tool, Mix, covering project creation, dependencies (via Hex), compilation, tests (ExUnit), and releases — where the Erlang world historically stitched together rebar3, relx, and assorted scripts. Both draw from the same Hex package registry, so an Erlang library is one {:dep, "~> x"} line away in an Elixir project.
Gotchas for Erlang Devs
Double quotes are binaries, not charlists
io:format("~w~n", ["abc"]). % [97,98,99] — a charlist (raw list via ~w)
IO.inspect("abc") # "abc" — a binary IO.inspect(~c"abc") # ~c"abc" — a charlist (rarely needed)
The reflex to fix first: in Elixir "abc" is a UTF-8 binary, not a list of code points. When an Erlang API insists on a charlist, write ~c"abc" or call String.to_charlist/1. Most of the time you will simply stop thinking about charlists — binaries are the default and the right choice.
Variables rebind — that is not mutation
%% In Erlang, re-binding X is a "no match" error, full stop. X = 1, io:format("~p~n", [X]).
x = 1 x = x + 1 # perfectly legal — rebinds x x = "now a string" IO.puts(x)
Coming from single-assignment Erlang, Elixir's rebinding can look like mutation — it is not. Each x = ... creates a fresh binding of an immutable value; anything that captured the old x (a closure, a spawned process) still sees the old value. You gain the convenience of reusing a name without giving up any of Erlang's immutability guarantees.
Small operator and comparison differences
%% Erlang: =:= is exact equality, =/= is exact inequality, %% andalso / orelse are the short-circuit booleans. io:format("~p ~p~n", [1 =:= 1, true andalso false]).
# Elixir: === is exact equality, and / or short-circuit on booleans. IO.puts("#{1 === 1} #{true and false}")
A few operators are respelled. Erlang's exact-equality =:= and =/= become Elixir's === and !==; the short-circuit andalso/orelse become and/or (which, like Erlang's, require real booleans), while &&/|| work on any value with nil/false as falsy. List concatenation stays ++, but string concatenation is <>, not ++.