cv.cm

Coding · Programming languages

Elixir tutorial for beginners

7 min read

Short answer

Elixir is a dynamic, functional language that runs on the Erlang virtual machine (BEAM), designed for scalable, fault-tolerant and concurrent systems. Its core ideas are immutable data, pattern matching, the pipe operator and lightweight processes that communicate by messages. This page covers setup, types, pattern matching, control flow, modules and functions, collections, processes and error handling.

Open Regex tester

What Elixir is and what it is used for

Elixir is a dynamic, functional language created by José Valim and first released in 2012. It runs on the BEAM, the virtual machine of Erlang, so it inherits Erlang's lightweight processes, fault tolerance and support for distributed systems, while adding a friendly syntax, macros and modern tooling.

Elixir is used for web applications and APIs with the Phoenix framework, real-time features such as chat and live dashboards (Phoenix LiveView), messaging systems, data pipelines, embedded devices with Nerves, and machine learning with Nx. Companies choose it for services that must handle many concurrent connections and keep running when parts fail.

Install Elixir and run Hello, world

Install Elixir from elixir-lang.org (it also installs or requires Erlang/OTP). Scripts use the .exs extension; compiled project files use .ex. iex starts the interactive shell.

# hello.exs
IO.puts("Hello, world!")
elixir hello.exs
# Hello, world!
iex                      # interactive shell; type h(Enum.map) for help
mix new my_app           # create a new project with Mix, the build tool

Comments start with #. Parentheses around function arguments are optional in many places, so IO.puts "hi" also works.

Basic types and immutability

All data in Elixir is immutable. Rebinding a name with = points it to a new value; it never changes the old one. Strings are UTF-8 binaries written in double quotes, and atoms are named constants that start with a colon.

name = "Elixir"
year = 2012
IO.puts("#{name} since #{year}")    # Elixir since 2012
IO.puts(10 / 4)                     # 2.5 (always a float)
IO.puts(div(10, 4))                 # 2
IO.puts(rem(10, 4))                 # 2
IO.puts("ab" <> "cd")               # abcd (string concatenation)
IO.puts(String.length("héllo"))     # 5 (counts characters, not bytes)
IO.inspect(:ok)                     # :ok
IO.inspect(is_atom(true))           # true (booleans are atoms)
TypeExampleNotes
integer42, 1_000_000arbitrary precision
float3.1464-bit double precision
atom:ok, :errorconstants; true, false and nil are atoms
string"hello"UTF-8 binary; #{} interpolation
list[1, 2, 3]linked list
tuple{:ok, 1}fixed size, stored contiguously
map%{"a" => 1}key-value store

Pattern matching

In Elixir, = is the match operator. The left side is a pattern; if the right side fits it, variables in the pattern are bound. Pattern matching is used everywhere: in assignments, case, function heads and error handling.

{:ok, value} = {:ok, 42}
IO.puts(value)                         # 42

[first | rest] = [1, 2, 3]
IO.inspect({first, rest})              # {1, [2, 3]}

%{name: n} = %{name: "Ann", age: 31}
IO.puts(n)                             # Ann

result = {:error, :not_found}
message =
  case result do
    {:ok, v} -> "got #{v}"
    {:error, reason} -> "failed: #{reason}"
  end
IO.puts(message)                       # failed: not_found

x = 1
^x = 1                                 # pin operator: match the current value

If a pattern does not match, Elixir raises a MatchError. The pin operator ^ means "use the existing value of this variable" instead of rebinding it.

Control flow

Elixir has case, cond and if/unless, and all of them return values. There are no for/while loops that mutate counters; you use recursion, the Enum module or the for comprehension instead.

temp = 22
feel =
  cond do
    temp > 30 -> "hot"
    temp >= 15 -> "mild"
    true -> "cold"
  end
IO.puts(feel)                                   # mild

IO.puts(if(rem(temp, 2) == 0, do: "even", else: "odd"))  # even

squares = for n <- 1..5, rem(n, 2) == 1, do: n * n
IO.inspect(squares)                             # [1, 9, 25]

Only false and nil are falsy; everything else, including 0 and "", is truthy.

Functions, modules and the pipe operator

Named functions live in modules defined with defmodule. A function can have several clauses, chosen by pattern matching and guards (when). Anonymous functions are written fn ... end and called with a dot. The pipe operator |> passes the result of one expression as the first argument of the next call.

defmodule Math do
  def factorial(0), do: 1
  def factorial(n) when n > 0, do: n * factorial(n - 1)

  def sign(n) when n < 0, do: :negative
  def sign(0), do: :zero
  def sign(_n), do: :positive
end

IO.puts(Math.factorial(5))          # 120
IO.inspect(Math.sign(-3))           # :negative

double = fn x -> x * 2 end
IO.puts(double.(21))                # 42 (note the dot)

"  Elixir is fun  "
|> String.trim()
|> String.upcase()
|> String.split()
|> IO.inspect()                     # ["ELIXIR", "IS", "FUN"]

Functions are identified by name and arity (number of arguments), written as String.split/1 or Math.factorial/1 in documentation.

Collections: lists, maps, keyword lists and Enum

The Enum module works with any enumerable collection and provides map, filter, reduce, sort_by, group_by and many more. Maps are the main key-value structure; keyword lists, such as [size: 10, color: :red], are commonly used for options.

nums = [5, 3, 8, 1]
IO.inspect(Enum.sort(nums))                      # [1, 3, 5, 8]
IO.inspect(Enum.map(nums, &(&1 * 10)))           # [50, 30, 80, 10]
IO.inspect(Enum.filter(nums, fn n -> n > 2 end)) # [5, 3, 8]
IO.puts(Enum.reduce(nums, 0, &+/2))              # 17

user = %{name: "Ann", age: 31}
older = %{user | age: 32}                        # update an existing key
IO.puts(user.age)                                # 31 (unchanged)
IO.puts(older.age)                               # 32
with_city = Map.put(user, :city, "Oslo")
IO.puts(with_city.city)                          # Oslo
IO.inspect(Map.get(user, :email, "none"))        # "none"

The capture operator & creates short anonymous functions: &(&1 * 10) means fn x -> x * 10 end, and &+/2 refers to the + function with two arguments.

Processes and concurrency

Elixir's signature feature is the BEAM process: a very lightweight, isolated unit of execution with its own memory. Processes communicate only by sending messages, and a single machine can run hundreds of thousands of them. Supervisors restart processes that crash, which is the basis of Elixir's "let it crash" approach to fault tolerance.

parent = self()

spawn(fn ->
  send(parent, {:result, 6 * 7})
end)

receive do
  {:result, value} -> IO.puts("Got #{value}")   # Got 42
after
  1_000 -> IO.puts("timeout")
end

tasks = for n <- 1..3, do: Task.async(fn -> n * n end)
IO.inspect(Task.await_many(tasks))               # [1, 4, 9]

In real applications you rarely call spawn directly. You use abstractions from OTP such as Task, GenServer, Agent and Supervisor.

Error handling

Idiomatic Elixir returns tagged tuples such as {:ok, value} or {:error, reason} for expected failures and uses case or with to handle them. Functions whose names end in !, such as File.read!/1, raise an exception instead. Exceptions can be caught with try/rescue, but this is uncommon.

case File.read("missing.txt") do
  {:ok, text} -> IO.puts(text)
  {:error, reason} -> IO.puts("Cannot read: #{reason}")   # Cannot read: enoent
end

with {a, ""} <- Integer.parse("40"),
     {b, ""} <- Integer.parse("2") do
  IO.puts(a + b)                                         # 42
else
  _ -> IO.puts("not a whole number")
end

try do
  String.to_integer("abc")
rescue
  e in ArgumentError -> IO.puts("Error: #{Exception.message(e)}")
end

with chains several matches and stops at the first one that does not match, returning that value. It avoids deeply nested case expressions.

A small complete program: word frequency

This script counts words in a text and prints the three most common ones. It is a typical Elixir pipeline built from String and Enum functions.

defmodule WordFreq do
  def top(text, n) do
    text
    |> String.downcase()
    |> String.split(~r/[^a-z']+/, trim: true)
    |> Enum.frequencies()
    |> Enum.sort_by(fn {word, count} -> {-count, word} end)
    |> Enum.take(n)
  end
end

"The cat and the dog. The dog sat, and the cat ran."
|> WordFreq.top(3)
|> Enum.each(fn {word, count} -> IO.puts("#{word}: #{count}") end)
# the: 4
# and: 2
# cat: 2

Where to go next

  • The official Getting Started guide on hexdocs.pm and the elixir-lang.org website.
  • HexDocs, where the documentation of Elixir and every Hex package is published.
  • The Phoenix framework guides for web development.
  • Elixir School, a free community curriculum, and Exercism's Elixir track for practice.

FAQ

What is the difference between Elixir and Erlang?

Both run on the same BEAM virtual machine and can call each other's code. Elixir adds a Ruby-inspired syntax, macros, the pipe operator and tools such as Mix and Hex, while Erlang is the older language that OTP was built in.

Is Elixir good for web development?

Yes. The Phoenix framework is fast and handles many simultaneous connections, and LiveView lets you build interactive pages with server-rendered HTML and little JavaScript.

Is Elixir statically typed?

Elixir is dynamically typed. Recent versions are adding a gradual set-theoretic type system to the compiler, and many teams also use Dialyzer with typespecs to find type errors.

What does |> mean in Elixir?

|> is the pipe operator. x |> f(y) is the same as f(x, y), so a chain of transformations can be read from top to bottom.

What are .ex and .exs files?

.ex files are compiled as part of a project, while .exs files are scripts that are interpreted when run, commonly used for tests and configuration.

All coding tutorials