Learn Elixir

A friendly, extensible language on the BEAM with Ruby-influenced syntax, powerful macros, and the Phoenix web framework.

Getting Started: IEx, Values, and Modules

Install Elixir, explore values in IEx, and write your first module and function.

Elixir is a dynamic, functional language created by José Valim in 2011. It compiles to bytecode for the BEAM (the Erlang virtual machine), so it inherits Erlang's battle-tested concurrency and fault tolerance while offering a friendlier, Ruby-influenced syntax.

The fastest way to learn is the interactive shell, IEx (Interactive Elixir). Start it from a terminal with iex, then type expressions and see results immediately:

iex> 1 + 2
3
iex> "hello" <> " " <> "world"
"hello world"
iex> String.upcase("elixir")
"ELIXIR"
iex> div(10, 3)
3
iex> rem(10, 3)
1

Elixir has a small set of core data types. Integers and floats are numbers (42, 3.14); atoms are named constants whose value is their own name (:ok, :error, :elixir); booleans true and false are actually the atoms :true and :false; and strings are UTF-8 binaries written with double quotes.

iex> is_atom(:ok)
true
iex> is_binary("text")
true
iex> i "hi"   # the i/1 helper describes any term

Real code lives in modules and functions. A file ending in .exs is a script you run directly; files ending in .ex are compiled. Create math.exs:

defmodule Math do
  @moduledoc "Tiny arithmetic helpers."

  @doc "Adds two numbers."
  def add(a, b) do
    a + b
  end

  # Short, one-line form using `do:`
  def double(n), do: n * 2
end

IO.puts(Math.add(2, 3))   # => 5
IO.puts(Math.double(21))  # => 42

Run it with elixir math.exs, or load it into the shell with iex math.exs so you can call Math.add/2 interactively. The notation add/2 means "the function add that takes 2 arguments"; arity (the argument count) is part of a function's identity in Elixir, so add/2 and add/3 are different functions.

A few conventions worth knowing early: variables and function names are snake_case, module names are CamelCase, and the last expression in a function is its return value (there is no return keyword). The @moduledoc and @doc attributes above attach documentation that tooling like h Math.add in IEx and HexDocs can display.

Pattern Matching and Immutable Data

The match operator, destructuring tuples/lists/maps, and rebinding immutable values.

In Elixir, = is not assignment - it is the match operator. The left side is a pattern matched against the value on the right. If they match, any variables in the pattern are bound; if they cannot match, you get a MatchError.

iex> x = 1        # binds x to 1
1
iex> 1 = x        # matches: 1 equals x, so this succeeds
1
iex> 2 = x        # ** (MatchError) no match of right hand side value: 1

This becomes powerful when destructuring compound data. Tuples (fixed-size, written with {}) and lists (variable-size, written with []) can be pulled apart by shape:

iex> {a, b, c} = {:ok, 200, "OK"}
iex> b
200
iex> [head | tail] = [1, 2, 3, 4]
iex> head
1
iex> tail
[2, 3, 4]

The [head | tail] pattern is the idiomatic way to split a list into its first element and the rest - the foundation of recursion over lists. A very common idiom is matching on tagged tuples returned by functions:

case File.read("config.txt") do
  {:ok, contents} -> IO.puts("Got: " <> contents)
  {:error, reason} -> IO.puts("Failed: #{inspect(reason)}")
end

Maps are key/value stores. You can match on the keys you care about and ignore the rest:

iex> user = %{name: "Ada", role: :admin}
iex> %{name: name} = user
iex> name
"Ada"
iex> user.name        # dot access works for atom keys
"Ada"

Elixir data is immutable: functions never change existing structures, they return new ones. "Updating" a map with the %{map | key => value} syntax produces a fresh map and leaves the original untouched:

iex> u1 = %{name: "Ada", role: :user}
iex> u2 = %{u1 | role: :admin}
iex> u1.role   # :user  (unchanged)
iex> u2.role   # :admin (new map)

Rebinding a variable (x = 1 then x = 2) is allowed and just points the name at a new value; the old value itself is never mutated. If you want to match against a variable's current value instead of rebinding it, use the pin operator ^:

iex> expected = 42
iex> ^expected = 42   # matches
iex> ^expected = 7    # ** (MatchError)

Functions can also have multiple clauses matched top to bottom, often with guards (when) for extra conditions:

defmodule Sign do
  def of(n) when n > 0, do: :positive
  def of(n) when n < 0, do: :negative
  def of(0), do: :zero
end

The Pipe Operator and Working with Collections

Chain transformations with |> and process lists with Enum and comprehensions.

One of Elixir's signature features is the pipe operator |>. It takes the value on its left and passes it as the first argument of the function call on its right. This turns deeply nested calls into a readable, top-to-bottom data pipeline.

Without the pipe, transformations read inside-out:

String.split(String.downcase(String.trim("  Hello World  ")))

With the pipe, they read in the order they happen:

"  Hello World  "
|> String.trim()
|> String.downcase()
|> String.split()
# => ["hello", "world"]

The pipe shines with the Enum module, the workhorse for collections (lists, maps, ranges, and anything else that implements the Enumerable protocol). Enum functions take the collection as their first argument, so they chain naturally:

1..10
|> Enum.filter(fn n -> rem(n, 2) == 0 end)   # keep even numbers
|> Enum.map(fn n -> n * n end)                # square them
|> Enum.sum()
# => 220   (4 + 16 + 36 + 64 + 100)

Elixir supports a compact function syntax with the capture operator &, where &1, &2, ... are the arguments. These two lines are equivalent:

Enum.map([1, 2, 3], fn x -> x * 2 end)
Enum.map([1, 2, 3], &(&1 * 2))

You can also capture an existing named function by name and arity:

["a", "b", "c"] |> Enum.map(&String.upcase/1)
# => ["A", "B", "C"]

Other frequently used Enum functions include Enum.reduce/3 for folding a collection into a single accumulator, and Enum.group_by/2:

Enum.reduce([1, 2, 3, 4], 0, fn n, acc -> acc + n end)   # => 10
Enum.group_by([1, 2, 3, 4, 5], &(rem(&1, 2)))            # => %{0 => [2, 4], 1 => [1, 3, 5]}

For declarative filtering and transforming, Elixir also has comprehensions with the for keyword. They combine generators (<-), optional filters, and a body:

for n <- 1..10, rem(n, 2) == 0, do: n * n
# => [4, 16, 36, 64, 100]

# Multiple generators produce a cartesian product:
for x <- [1, 2], y <- [:a, :b], do: {x, y}
# => [{1, :a}, {1, :b}, {2, :a}, {2, :b}]

When you need to process large or infinite sequences without building intermediate lists at each step, reach for the lazy Stream module, which has the same API as Enum but only computes values as they are consumed:

1..1_000_000
|> Stream.map(&(&1 * 3))
|> Stream.filter(&(rem(&1, 2) == 0))
|> Enum.take(5)
# => [6, 12, 18, 24, 30]   (only computes what Enum.take needs)

Control Flow: case, cond, with, and Recursion

Express decisions with case/cond/if, chain matches using with, and loop via recursion.

Because everything in Elixir is an expression that returns a value, its control-flow constructs are themselves expressions you can assign or pipe.

case matches a value against patterns (with optional guards), running the first branch that matches:

case Integer.parse("42") do
  {number, ""} -> "got the integer #{number}"
  {_number, rest} -> "trailing junk: #{rest}"
  :error -> "not a number"
end

cond is like a chain of if/else-if: it evaluates each condition in order and runs the body of the first truthy one. Use it when branches depend on different tests rather than on the shape of one value:

score = 87

cond do
  score >= 90 -> "A"
  score >= 80 -> "B"
  score >= 70 -> "C"
  true -> "F"          # `true` is the catch-all default
end
# => "B"

There is also a plain if (and unless), which is itself a macro. Note Elixir's notion of truthiness: only false and nil are falsy; everything else, including 0 and "", is truthy.

if user_logged_in? do
  "welcome"
else
  "please sign in"
end

The with expression is excellent for happy-path code that threads several pattern matches together. Each clause must match; if one fails, with short-circuits and returns the non-matching value (or runs an optional else). This avoids deeply nested case statements:

with {:ok, file}    <- File.read("config.json"),
     {:ok, config}  <- Jason.decode(file),
     {:ok, port}    <- Map.fetch(config, "port") do
  {:ok, port}
else
  {:error, reason} -> {:error, reason}
  :error -> {:error, :missing_port}
end

Elixir has no for/while loops for iteration the way imperative languages do - instead you recurse, usually over [head | tail] list patterns. Multi-clause functions make recursion read cleanly, with a base case for the empty list:

defmodule MyList do
  # base case: an empty list sums to 0
  def sum([]), do: 0
  # recursive case: first element plus the sum of the rest
  def sum([head | tail]), do: head + sum(tail)
end

MyList.sum([1, 2, 3, 4])   # => 10

For large inputs, prefer tail-recursive versions that carry an accumulator, since the BEAM optimizes tail calls into constant stack space:

defmodule Fast do
  def sum(list), do: do_sum(list, 0)
  defp do_sum([], acc), do: acc
  defp do_sum([h | t], acc), do: do_sum(t, acc + h)
end

(defp defines a private function, callable only inside its own module.) In day-to-day code you will often reach for Enum.reduce/3 instead of writing recursion by hand, but understanding recursion is essential to thinking in Elixir.

Concurrency: Processes, GenServer, and Supervisors

Spawn lightweight processes, hold state in a GenServer, and keep them alive with supervision.

Elixir's superpower comes from the BEAM's concurrency model. A process here is not an OS process or thread - it is an extremely lightweight, isolated unit of execution managed by the VM. A single machine can run millions of them. Processes share no memory; they communicate only by sending messages.

You can spawn one with spawn, send it a message with send, and receive messages with receive:

pid = spawn(fn ->
  receive do
    {:greet, name} -> IO.puts("Hello, #{name}!")
  end
end)

send(pid, {:greet, "Ada"})
# => Hello, Ada!

Writing spawn/send/receive by hand is rare. Instead Elixir builds on OTP, Erlang's set of battle-tested abstractions for building reliable systems. The most important is GenServer (generic server), which wraps the receive loop and lets you hold state and respond to synchronous calls and asynchronous casts.

Here is a simple counter as a GenServer. The Counter module defines both the client API (functions other code calls) and the server callbacks (how it handles messages):

defmodule Counter do
  use GenServer

  # --- Client API ---
  def start_link(initial \\ 0) do
    GenServer.start_link(__MODULE__, initial, name: __MODULE__)
  end

  def increment, do: GenServer.cast(__MODULE__, :increment)
  def value, do: GenServer.call(__MODULE__, :value)

  # --- Server callbacks ---
  @impl true
  def init(initial), do: {:ok, initial}

  @impl true
  def handle_cast(:increment, count), do: {:noreply, count + 1}

  @impl true
  def handle_call(:value, _from, count), do: {:reply, count, count}
end

Using it feels like calling ordinary functions, but the state lives safely inside its own process:

iex> Counter.start_link(0)
iex> Counter.increment()
iex> Counter.increment()
iex> Counter.value()
2

The difference between the two message styles matters: call is synchronous and waits for a reply (so the caller blocks until the server responds), while cast is fire-and-forget and returns immediately.

The other half of OTP is the "let it crash" philosophy. Rather than defensively guarding against every error, you let a faulty process crash and have a supervisor restart it into a known-good state. You declare a supervision tree describing what to run and how to restart it:

defmodule MyApp.Supervisor do
  use Supervisor

  def start_link(_), do: Supervisor.start_link(__MODULE__, :ok, name: __MODULE__)

  @impl true
  def init(:ok) do
    children = [
      {Counter, 0}
    ]
    # If Counter crashes, restart just it and continue.
    Supervisor.init(children, strategy: :one_for_one)
  end
end

With this structure, a crash is not a catastrophe - the supervisor restarts the child automatically, and the rest of the system keeps running. This is the foundation of the fault tolerance Elixir and Erlang are famous for, and it scales up to the Phoenix web framework, which runs each connection in its own supervised process.

Metaprogramming with Macros and the Mix Toolchain

Understand the AST, write a simple macro, and manage projects with Mix and Hex.

Elixir is a deeply extensible language: large parts of it - including if, unless, def, and defmodule - are not built-in keywords but macros defined in Elixir itself. Macros let libraries add new constructs that feel native, which is how frameworks like Phoenix and Ecto provide their expressive DSLs.

Macros work by manipulating the program's abstract syntax tree (AST) at compile time, before the code runs. You can inspect the AST of any expression with quote, which returns the code as data instead of evaluating it:

iex> quote do: 1 + 2
{:+, [context: Elixir, imports: [...]], [1, 2]}

Every Elixir expression is represented uniformly as a {operation, metadata, arguments} tuple - the same Lisp-like homoiconicity that makes metaprogramming tractable. A macro receives such quoted forms as arguments and returns a new quoted form to be injected at the call site. Inside quote, you splice in values with unquote:

defmodule MyMacros do
  # A macro that only runs its body when the condition is false.
  defmacro unless(condition, do: block) do
    quote do
      if !unquote(condition), do: unquote(block)
    end
  end
end

require MyMacros
MyMacros.unless(1 > 2, do: IO.puts("math still works"))
# => math still works

The key distinction: a regular function receives the values of its arguments at runtime, but a macro receives the unevaluated code (the AST) at compile time and decides what code to generate. That is why unless can choose not to evaluate its block at all. Elixir macros are also hygienic - variables they introduce will not accidentally clash with variables at the call site. The guideline is to reach for macros only when a function cannot do the job, since they add compile-time complexity.

Real projects are managed with Mix, Elixir's built-in build tool. Create a new project skeleton with:

# In your shell:
# mix new my_app
# cd my_app

That generates a standard layout: lib/ for source, test/ for ExUnit tests, and a mix.exs project file. Dependencies come from the Hex package manager and are declared in mix.exs:

defp deps do
  [
    {:jason, "~> 1.4"},
    {:phoenix, "~> 1.7"}
  ]
end

Common Mix commands you will use constantly:

# mix deps.get      # fetch dependencies from Hex
# mix compile       # compile the project
# mix test          # run the ExUnit test suite
# iex -S mix        # start IEx with your project loaded

Writing tests with ExUnit is straightforward and ships with every project:

defmodule MathTest do
  use ExUnit.Case

  test "add/2 sums two numbers" do
    assert Math.add(2, 3) == 5
  end
end

With pattern matching, the pipe operator, lightweight processes, OTP, and macros, you have the core of what makes Elixir productive and reliable. From here, the natural next steps are exploring Phoenix for web applications and Ecto for databases - both built on exactly these foundations.