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, foldl → reduce — 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 protocols — defimpl 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 ++.