Skip to main content

01 - Elixir from Scratch: Functional Paradigm, Pattern Matching and Pipe Operator

In Elixir = does not assign, it compares: pattern matching replaces nested ifs. Pipe operator, immutability and Enum versus Stream, with sources.

What do you think about this article?

At a Glance

In Elixir = does not assign, it compares: pattern matching replaces nested ifs. Pipe operator, immutability and Enum versus Stream, with sources.

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 the iex REPL
  • 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
  • Enum versus Stream: 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.

Installation with asdf, and the structure of a Mix project
# 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.

Basic types, to try in iex
# 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.

Match, case, pin operator
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.

Multiple clauses and guards
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.

From nesting to pipeline
# 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.

New data, not modified data
# 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.

A module with doc, spec, private functions and attributes
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.

Eager versus lazy
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: Stream plus Enum.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 - Kernel module - definition of the pipe |> (passing as first argument) and the closed list of functions allowed in guards. Consulted on 2026-11-08.
  • Elixir - Stream module - 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/elixir and erlang/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

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects