In Elixir the = sign does not assign: it compares. Writing
{:ok, value} = call() means "I demand that the result be a pair whose first
element is :ok, and if it is, give me the second". If the function returned
{:error, :timeout} the program stops right there, on the right line, instead of
carrying on with a variable that holds something unexpected.
This is the point to enter the language from if you come from Java, C# or Python, because everything else - the pipe operator, immutability, the way functions are written - follows from this choice. This article walks through those fundamentals in the order in which they rest on one another.
The code blocks in this article were not executed
The machine on which this article was written has no Elixir environment
(elixir --version and mix are not installed), so the outputs
shown in the comments do not come from a run. They are the documented
behaviour of the functions used, checked against the official documentation linked at the
bottom. Where the result depends on a non-obvious detail, the text says which page settles
it. Reproduce them in iex: it is the right way to read this article.
Why the BEAM, and what should not be attributed to it
Elixir is not just one functional language among many: it is a language that compiles for the BEAM, the Erlang virtual machine. Erlang was born in the Ericsson laboratories, and in his doctoral thesis Joe Armstrong dates the start of work on the language to 1986. The flagship product is the AXD301 ATM switch, first delivered in 1998, which by 2003 contained over 1.7 million lines of Erlang.
The "99.9999999% uptime" is quoted far more often than it is documented
The famous "nine nines" figure circulates as if it were a measurement. The primary source is Armstrong's thesis, and it says something rougher: the only information on the long-term stability of the AXD301 came from "a PowerPoint presentation showing some figures according to which an important customer had run an 11-node system with 99.9999999% reliability", and Armstrong adds that how those figures had been obtained was not documented.
In the same chapter he recounts having searched the logs for evidence of hot code upgrades and not having found any, because the system was not instrumented to record them. It is worth knowing this for two reasons: because it is correct, and because the interesting thing about the AXD301 is not a number on a slide, it is the 1.7 million lines in service. Here the number stays only with its provenance attached.
The route of this article
- Installation with
asdf, and theiexREPL - The types you will actually use: atom, tuple, list, map, keyword list
- Pattern matching, from tuples to function heads with guards
- The pipe operator
|>, and the first-argument rule - Immutability, and why rebinding does not violate it
- Modules, private functions, capture syntax
EnumversusStream: when the computation happens
The first two items are instrumental, the third is the heart: if you take home only pattern
matching you have already changed the way you write code, because it replaces most nested
ifs with separate function heads. The last two are the ones a beginner skips and
then pays for: the difference between Enum and Stream decides
whether your program loads a 2 GB file into memory or reads it one line at a time.
Installation and first project
Elixir depends on a compatible Erlang/OTP version, so the least painful route is a version
manager that installs both. asdf does exactly that.
# The two plugins: Erlang first, Elixir second (Elixir needs the VM)
asdf plugin add erlang
asdf plugin add elixir
asdf install erlang 29.1.2
asdf install elixir 1.20.5-otp-29
asdf global erlang 29.1.2
asdf global elixir 1.20.5-otp-29
elixir --version
# New project
mix new my_app
cd my_app
# REPL with the project already compiled and loaded
iex -S mix
# Structure generated by `mix new`
# my_app/
# |- lib/
# | \- my_app.ex main module
# |- test/
# | \- my_app_test.exs tests (.exs extension: not compiled)
# |- mix.exs configuration and dependencies
# \- README.md
Two details deserve attention. The first is the -otp-29 suffix in the Elixir
version: Elixir builds are compiled against a specific OTP series, and installing
1.20.5 without the suffix on a VM of a different series produces warnings or
compilation errors.
The second is iex -S mix, which is not the same as a bare iex: with
-S mix the REPL starts after compiling and loading the project, so your modules
are already available. Without it you get an empty session and none of the modules you wrote.
On versions: Elixir 1.20.5 and OTP 29.1.2 are the releases published on 2026-10-29 and 2026-10-14 respectively according to the official releases of the two projects on GitHub, consulted on 2026-11-08. These are numbers that age: check them yourself instead of copying them, because a tutorial that pins an old version is the most common way to lose an afternoon on an error that has nothing to do with you.
The types you will actually use
Elixir has few types, and three of them do almost all the work: the atom as a label, the tuple as a fixed-position record, the map as a dictionary. The keyword list looks redundant and is not.
# Atom: a constant whose value is its own name
:ok
:error
true # true, false and nil are atoms: :true, :false, :nil
# Numbers
42
3.14
1_000_000 # the underscore is only for readability
# Strings: UTF-8 binaries
"Hello, world!"
"Interpolation: #{1 + 1}" # "Interpolation: 2"
:hello == "hello" # false: atom and string are different types
# Tuple: fixed length, access by position
{:ok, "value"}
{:error, :not_found}
# List: linked list, efficient on head/tail
[1, 2, 3, 4, 5]
[head | tail] = [1, 2, 3] # head = 1, tail = [2, 3]
# Map: key-value
%{name: "Mario", age: 35} # atom keys
%{"name" => "Mario", "age" => 35} # string keys
# Keyword list: list of {atom, value} tuples, ordered, duplicates allowed
[name: "Mario", age: 35, city: "Milano"]
# it is sugar for: [{:name, "Mario"}, {:age, 35}, {:city, "Milano"}]
# Range
1..10
1..10//2 # with step 2: 1, 3, 5, 7, 9
The distinction between tuple and list is a performance choice, not a matter of taste: the
tuple is contiguous in memory and is read by index in constant time, the list is linked and to
reach the last element it has to be traversed in full. This is why the convention for results
is a tuple: {:ok, value} or {:error, reason}. Two
positions, the first always a label.
The difference between map and keyword list is the one that confuses at first. The map has no
order and does not allow duplicate keys; the keyword list keeps the order and allows
repetitions. This is why function options are keyword lists:
Repo.all(query, timeout: 5000, log: false) is a keyword list passed as the last
argument, and the syntax without square brackets works only in that position.
The // of the range with a step has existed since version 1.12 of the language: on
an older installation it is a syntax error.
Pattern matching: where everything is decided
= is the match operator. The left side is a pattern, the right side a
value: Elixir tries to make the shape fit, binds the variables it finds in the pattern and, if
the shape does not fit, raises MatchError.
x = 42 # binds x to 42
42 = x # successful match: assigns nothing, it checks
# 43 = x # ** (MatchError) no match of right hand side value: 42
# Destructuring tuples
{:ok, value} = {:ok, "result"} # value = "result"
# {:ok, value} = {:error, :not_found} # MatchError: :ok does not match :error
# case: tries the patterns in order, from the top
result = {:error, :timeout}
case result do
{:ok, value} -> IO.puts("Success: #{value}")
{:error, :not_found} -> IO.puts("Not found")
{:error, reason} -> IO.puts("Error: #{reason}")
_ -> IO.puts("Any other case")
end
# prints: Error: timeout
# Lists: head and tail
[first | rest] = [1, 2, 3, 4, 5] # first = 1, rest = [2, 3, 4, 5]
[a, b | remaining] = [10, 20, 30, 40] # a = 10, b = 20, remaining = [30, 40]
# Map: the match is partial, extra keys do not get in the way
%{name: name} = %{name: "Mario", age: 35, city: "Milano"} # name = "Mario"
# Pin operator ^: compares with the current value instead of rebinding
existing = 42
^existing = 42 # ok
# ^existing = 43 # MatchError
The order of the case clauses is not irrelevant: Elixir takes the
first one that matches. In the example, {:error, :timeout} slides
past {:error, :not_found} and stops at {:error, reason}, which binds
reason to :timeout. If you moved the generic clause
{:error, reason} to the top, the two lines below it would become dead code - and
the compiler tells you so with an unreachable-clause warning.
The final _ is the fallback clause, and here there is a trade-off to choose with
your head: putting it in avoids a CaseClauseError at runtime, but it also switches
off the warning that tells you that you forgot a case. When the possible cases are known and
finite - the states of an order, the codes your API can return - not putting in the
_ is often the better choice: you would rather find out during development that a
new case is not handled.
On the pin operator: without ^, writing existing = 43 compares
nothing, it simply rebinds existing to 43. It is the difference between "check
that it is 43" and "from now on it is 43", and inside a case it is the difference
between a comparison and a silent bug.
The same mechanism in function heads
Pattern matching does not live only inside case: you can write several definitions
of the same function, each with a different pattern. This is where the language changes the way
you organize code.
defmodule HttpResponse do
def handle({:ok, %{status: 200, body: body}}), do: IO.puts("OK: #{body}")
def handle({:ok, %{status: 404}}), do: IO.puts("Not Found")
# Guard: a condition that the pattern alone cannot express
def handle({:ok, %{status: status}}) when status >= 500 do
IO.puts("Server error: #{status}")
end
def handle({:error, reason}), do: IO.puts("Request failed: #{reason}")
end
HttpResponse.handle({:ok, %{status: 200, body: "hello"}}) # OK: hello
HttpResponse.handle({:ok, %{status: 503}}) # Server error: 503
HttpResponse.handle({:error, :timeout}) # Request failed: timeout
defmodule Validator do
def validate_age(age) when is_integer(age) and age >= 0 and age <= 150 do
{:ok, age}
end
def validate_age(age) when is_integer(age) do
{:error, "age #{age} outside the range 0-150"}
end
def validate_age(_), do: {:error, "age must be an integer"}
end
In HttpResponse there are four functions with the same name and the same arity:
Elixir tries them in the order they are written and uses the first one whose pattern matches.
Written in Java, this would be a switch on the status inside an if on
success, with two levels of nesting; here every case is an independent line that you can read
on its own.
The when status >= 500 clause shows the border between pattern and guard: a
pattern can only describe a shape, so "status equal to 404" is expressed with the
pattern, but "status greater than or equal to 500" needs a guard. Guards accept only a closed
list of functions - is_integer/1, comparisons, arithmetic - and not just any
function of yours: the guards page in the documentation lists which ones.
In Validator the order is load-bearing. The first clause accepts the integers in
range, the second catches the integers out of range, the third everything else. If you swapped
the first with the second, every integer would fall into the second and the validation
would never say :ok. It is the kind of error that does not produce an exception:
it produces a program that rejects everything.
The pipe operator, and the first-argument rule
|> takes the value on the left and passes it as the first
argument to the function on the right. All of its usefulness - and its one limit - lies in that
word.
# Without pipe: read from the inside out, opposite to the order of execution
result = Enum.sum(Enum.filter(Enum.map([1, 2, 3, 4, 5], fn x -> x * 2 end), fn x -> x > 4 end))
# With pipe: read from top to bottom, in the order in which it happens
result =
[1, 2, 3, 4, 5]
|> Enum.map(fn x -> x * 2 end) # [2, 4, 6, 8, 10]
|> Enum.filter(fn x -> x > 4 end) # [6, 8, 10]
|> Enum.sum() # 24
# A concrete case: the list of users to show on a page
defmodule UserProcessor do
def process_active_users(users) do
users
|> Enum.filter(&active?/1)
|> Enum.sort_by(& &1.name)
|> Enum.map(&enrich_with_metadata/1)
|> Enum.take(50)
end
defp active?(%{status: :active}), do: true
defp active?(_), do: false
defp enrich_with_metadata(user) do
Map.put(user, :display_name, format_display_name(user))
end
defp format_display_name(%{name: name, city: city}), do: "#{name} (#{city})"
defp format_display_name(%{name: name}), do: name
end
users = [
%{name: "Mario", status: :active, city: "Milano"},
%{name: "Luigi", status: :inactive, city: "Roma"},
%{name: "Peach", status: :active, city: "Torino"}
]
UserProcessor.process_active_users(users)
# Luigi drops out at the filter; Mario and Peach remain, sorted by name,
# each with :display_name added - "Mario (Milano)", "Peach (Torino)"
Compare the two forms on the same logic. In the first line the operation that happens first,
Enum.map, is written in the middle, and to understand the order you have to read
from the inside out; in the pipeline the order of writing is the order of execution. It is not
an aesthetic matter: it is the reason why you can comment out one line of the pipeline to
exclude a step and the rest keeps compiling.
In process_active_users the order of the steps has a cost.
Enum.take(50) sits at the bottom, so enrich_with_metadata is applied
to all the active users, not to the 50 you will show. Moving take before
map would do less work - but only because here the sorting comes first: if you
removed sort_by, moving take earlier would change which 50
users you get, not just how much it costs. It is the kind of choice that the pipe makes
visible, and it is the reason to write it this way.
On the syntax &active?/1 and & &1.name: it is the capture
syntax, a shorthand for fn x -> active?(x) end and
fn x -> x.name end. &1 is the first argument. Finally, the trap
of the pipe: it works only if the value you are passing is the first parameter of the function.
With String.replace/3, where the subject is the first argument, |>
works; with a function that expects the data as its second argument you cannot use it and you
have to write an explicit fn.
Immutability, and why rebinding does not violate it
No data structure in Elixir is modified. The functions that seem to modify return a new structure and leave intact the one they received. It is the precondition of the BEAM's concurrency: if nobody can alter a piece of data that someone else is reading, there is no need to synchronize access.
# Lists
original = [1, 2, 3]
new_list = [0 | original] # [0, 1, 2, 3]
original # still [1, 2, 3]
# Map
user = %{name: "Mario", age: 35}
updated = Map.put(user, :city, "Milano")
# updated = %{name: "Mario", age: 35, city: "Milano"}
user # still %{name: "Mario", age: 35}
# Sugar for updating keys that already exist
older = %{user | age: 36} # %{name: "Mario", age: 36}
# %{user | city: "Roma"} # KeyError: :city does not exist in user
# Rebinding: it is NOT mutation
x = 1
x = x + 1 # x is now 2; the number 1 has not changed,
# it is the label x that points to another value
The step to understand is the last one. x = x + 1 looks like the usual mutation,
and it is not: the value 1 is immutable and stays that way, what changes is which
value the name x is bound to. The difference becomes concrete when the old value
is still in someone's hands - another process, a closure - who will keep seeing
1.
The two update syntaxes of the map are not interchangeable, and that is intended.
Map.put/3 adds or overwrites, so it never fails; %{user | age: 36}
demands that the key exist and raises KeyError if it does not.
The second form is the safer one when you are updating a record whose shape you know, because a
typo in the key name becomes an exception instead of a new key added silently.
Note also that [0 | original] puts the element at the head, not at the tail. It is
not a preference: on a linked list, insertion at the head is constant-cost, while appending at
the tail has to rebuild the list. This is why the Elixir idiom is to accumulate at the head and
reverse at the end with Enum.reverse/1, rather than appending.
Modules and functions
A module is a container of functions and the unit of compilation. The annotations that go with it - documentation, type specifications, attributes - are not decoration: they are read by tools you really use.
defmodule MyApp.Calculator do
@moduledoc "Sample arithmetic operations."
@doc "Adds two integers."
@spec add(integer(), integer()) :: integer()
def add(a, b), do: a + b
# Two clauses: the degenerate case first, so no if is needed
@spec divide(number(), number()) :: {:ok, float()} | {:error, String.t()}
def divide(_, 0), do: {:error, "division by zero"}
def divide(a, b), do: {:ok, a / b}
# defp: invisible outside the module
defp validate_positive(n) when n > 0, do: :ok
defp validate_positive(_), do: :error
def run_examples do
double = fn x -> x * 2 end
triple = &(&1 * 3) # capture syntax
IO.puts(double.(5)) # 10 - the dot is needed to call an anonymous fn
IO.puts(triple.(5)) # 15
[1, 2, 3, 4, 5]
|> Enum.map(double)
|> IO.inspect() # [2, 4, 6, 8, 10]
end
end
defmodule Config do
# Module attributes: constants resolved at compile time
@max_retries 3
@base_url "https://api.example.com"
def max_retries, do: @max_retries
def base_url, do: @base_url
end
divide(_, 0) written before divide(a, b) is the whole
mechanism in one line: the special case intercepts, the general case collects the rest, and
there is no if. Swapping the two lines, divide(a, b) would also catch
b = 0 and you would get an ArithmeticError instead of the
{:error, ...} tuple you had promised in the @spec.
The dot in double.(5) is not a typo. Anonymous functions are called with
.(), named ones without the dot, and the reason is that in Elixir
double alone is a variable like any other: the dot says "call the value inside".
Remove it and you get an undefined-function error.
On @spec and @doc it is worth being explicit, because they look
optional: @doc ends up in the documentation generated by ExDoc and can be
consulted from iex with h MyApp.Calculator.add; @spec is
read by Dialyzer, the static analyzer, which flags incompatible calls. Without
@spec the code compiles and works, but Dialyzer has much less to check.
@max_retries, instead, is resolved at compile time: changing it requires a
recompilation, restarting is not enough.
Enum or Stream: the question is "when does it compute"
The two libraries expose almost the same functions and differ on one point: Enum
computes immediately and returns a collection, Stream builds a recipe and computes
nothing until someone asks for the result.
numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10]
Enum.map(numbers, fn x -> x * x end)
# [1, 4, 9, 16, 25, 36, 49, 64, 81, 100]
Enum.filter(numbers, &(rem(&1, 2) == 0))
# [2, 4, 6, 8, 10]
Enum.reduce(numbers, 0, fn x, acc -> acc + x end)
# 55
Enum.group_by(numbers, &rem(&1, 3))
# %{0 => [3, 6, 9], 1 => [1, 4, 7, 10], 2 => [2, 5, 8]}
# Stream: nothing is computed until Enum.take arrives
Stream.iterate(0, &(&1 + 1)) # infinite sequence
|> Stream.filter(&(rem(&1, 2) == 0))
|> Stream.map(&(&1 * &1))
|> Enum.take(5)
# [0, 4, 16, 36, 64]
# A big file, one line at a time
File.stream!("sales_2026.csv")
|> Stream.map(&String.trim/1)
|> Stream.filter(&String.contains?(&1, "2026-11"))
|> Enum.to_list()
The Stream.iterate example shows the difference in the sharpest possible way: the
starting sequence is infinite. If you replaced the three
Stream. with Enum., the first Enum.filter would try to
walk through all the integers and the program would never return. With Stream,
filter and map just compose the recipe, and the only function that
makes anything happen is Enum.take(5), which pulls exactly the five elements
requested and stops.
The file case is the same mechanism with an effect that is measured in memory.
File.stream!/1 does not read the file: it produces a stream of lines. If the same
chain were written with File.read!/1 and Enum, the whole file would
be loaded into memory before the first filter - on a CSV of a few gigabytes, this is the border
between a program that runs and one that is killed by the system.
The choice comes down to four recurring situations:
- Small collection of known size:
Enum - File, paginated HTTP response, database cursor:
Stream - Infinite sequence, or one whose length cannot be decided in advance:
Stream, no choice - You only need a prefix of an expensive result:
StreamplusEnum.take
The criterion that holds the four lines together is just one: how much of the work you can
avoid. If the answer is "none, they are all needed", Enum always wins, and beware
of the opposite cost - on a list of ten elements Stream is slower, because
building and dismantling the recipe costs more than the work it saves. The line that decides
most often in a real program is the second, because it is the one in which you do not choose
the size of the source.
What you can do now
You can read {:ok, value} as a pattern and not as an assignment, replace a nested
if with function clauses and guards, build a transformation as a pipeline, and
choose between Enum and Stream by looking at the size of the source.
These four gestures cover most of the Elixir code you will read.
The next step is the reason the BEAM exists: processes. In Elixir they are not operating-system threads, they share no memory and they talk by messages - and that is what BEAM and processes is about. From there you get to OTP and GenServer, where mutable state comes back, but confined inside a process and watched over by a supervisor.
If you are interested in the comparison with another concurrency model, the single-threaded event loop of Python is described in async and await: it is the opposite road to that of the BEAM, and seeing them side by side clarifies both.
Sources
-
Elixir - Pattern matching - the
semantics of
=as a match, the behaviour of partial matching on maps and the pin operator^. Consulted on 2026-11-08. -
Elixir -
Kernelmodule - definition of the pipe|>(passing as first argument) and the closed list of functions allowed in guards. Consulted on 2026-11-08. -
Elixir -
Streammodule - the definition of lazy enumerable and which functions force the computation. Consulted on 2026-11-08. - Joe Armstrong, "Making reliable distributed systems in the presence of software errors" (2003) - 1986 as the start of the work on Erlang, the AXD301 delivered in 1998 with over 1.7 million lines, and the paragraph of chapter 8.5 from which the quotation on 99.9999999% comes. PDF, consulted on 2026-11-08.
- Erlang/OTP - Design Principles - the framework in which processes, behaviours and supervision fit, taken up again in the later articles of the series. Consulted on 2026-11-08.
-
Versions - Elixir 1.20.5 and OTP 29.1.2 come from the release lists of the
two projects on GitHub (
elixir-lang/elixiranderlang/otp), queried on 2026-11-08: publication dates 2026-10-29 and 2026-10-14.
The first three entries are the language documentation, and it is the source to prefer for any doubt about a function, because it is versioned together with the code. The fourth is the only one worth reading in full even without writing Erlang: it is the document that explains why fault tolerance is a property of the architecture and not a library, and it is also the source that dismantles the most quoted number of this ecosystem.
Commenti
Caricamento commenti...
Sign in to comment