Skip to content
Elixir v1.20.4

elixir v1.20.4released 2026-08-28OTP 27+ · 29 compatible

The Elixir Cheatsheet.

A dense, single-page reference for experienced developers — from pattern matching to the gradually typed v1.20 compiler. Every entry links to hands-on exlings exercises. Inspired by cheats.rs.

92 entries · 17 sections · ≈75 min full read
coveragecore ×74

Install Elixir (latest stable)

Elixir v1.20.4 requires Erlang/OTP 27+ and is compatible with OTP 29. Prefer a version manager — asdf or mise — so you can pin per project.

Install via asdf (recommended)shell
asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git
asdf plugin add elixir https://github.com/asdf-vm/asdf-elixir.git
asdf install erlang latest
asdf install elixir latest
asdf global elixir latest

elixir --version
# Erlang/OTP 28 [erts-...] ...
# Elixir 1.20.4 (compiled with Erlang/OTP 28)
Code sample: asdf plugin add erlang https://github.com/asdf-vm/asdf-erlang.git asdf plugin ad
  • macOS: brew install elixir
  • Ubuntu/Debian: sudo apt install elixir (often older — prefer asdf/mise)
  • Windows: installer from elixir-lang.org/install or winget install ElixirLang.Elixir
  • Verify: elixir --version prints both OTP and Elixir versions

IEx — the interactive shell

since v1.20

REPL with autocomplete, history, breakpoints and helpers. The fastest way to poke at the standard library.

Everyday IExshell
iex
iex> 1 + 1
2
iex> h String.split          # documentation for a function
iex> String.split("a,b", ",")
["a", "b"]
iex> source(String.split)    # print/open source location (since v1.20)
iex> i 1_000_000             # introspect any value's type
iex> v 2                     # recall result of line 2
iex> h()                     # IEx help — lists all helpers
Code sample: iex iex> 1 + 1 2 iex> h String.split # documentation for a function iex
Run a project inside IExshell
iex -S mix          # start IEx with your Mix project compiled + loaded
# edit code, then:
iex> recompile()    # reload changed modules without leaving the shell
iex --dbg prye lets dbg/1 calls drop you into an interactive pry shell.
Code sample: iex -S mix # start IEx with your Mix project compiled + loaded # edit c

Hello, World — scripts vs projects

.exs scripts run top-to-bottom with elixir; real projects use mix new and compiled .ex sources.

hello.exs — a scriptelixir
# Comments start with #
IO.puts("Hello, World!")
name = "Elixir"
IO.puts("Hello, #{name}!")   # interpolation with #{…}

person = %{name: "Ada", lang: :elixir}
IO.inspect(person, label: "person")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Run with elixir hello.exs. .exs = scripted/evaluated each run; .ex = compiled to .beam.
Code sample: # Comments start with # IO.puts("Hello, World!") name = "Elixir" IO.puts("Hello,
A real Mix projectshell
mix new hello --sup          # --sup generates a supervision tree
cd hello
mix test                     # the scaffold ships with a passing test
mix run -e 'IO.puts("hi")'   # run one expression
MIX_OS_DEPS_COMPILE_PARTITION_COUNT=4 mix deps.compile  # parallel deps (1.19+)
mix format formats the whole project; run it before every commit — it is the community standard.
Code sample: mix new hello --sup # --sup generates a supervision tree cd hello mix t
What a generated project looks likeelixir
defmodule Hello.MixProject do
  use Mix.Project

  def project do
    [
      app: :hello,
      version: "0.1.0",
      elixir: "~> 1.20",
      start_permanent: Mix.env() == :prod,
      deps: deps()
    ]
  end

  def application do
    [extra_applications: [:logger], mod: {Hello.Application, []}]
  end

  defp deps, do: []
end
Code sample: defmodule Hello.MixProject do use Mix.Project def project do [ ap

The whole language in one view

How the pieces fit: Kernel is auto-imported, everything else is a module; data is immutable; functions live in modules; processes do the concurrency.

LayerWhat it isExamples
Kernel & special formsauto-imported basics: def, if, case, +, |>defmodule, =, for
Standard librarymodules of pure functions over immutable dataEnum, String, Map, Integer
Data typesimmutable values:atoms, ‘strings’, [lists], %{maps}, {tuples}, <<binaries>>
Mixbuild tool & task runnermix new, mix test, mix format
OTPprocesses, supervisors, behavioursGenServer, Supervisor, Task, Agent
BEAMthe VM: pre-emptive schedulers, per-process heaps, GCdistribution, hot reload
  • Everything is an expression — if, case, even defmodule return values.
  • Data is immutable — "modifying" a map returns a new map.
  • Pattern matching is everywhere — =, function clauses, case, for, binary syntax.
  • Errors are values first — {:ok, result} / {:error, reason} tuples; raise is for bugs.
  • The compiler is your reviewer — v1.20 gradually type checks every program and warns about verified bugs for free.

Modules & functions

All code lives in modules: defmodule opens one, def defines a public function, defp a private one, and the last expression is the return value.

defmodule, def, defp, module attributeselixir
defmodule Greet do
  @moduledoc "Docs for the whole module."
  @punct "!"                # module attribute = compile-time constant

  @doc "Greets a person by name."
  def greet(name, lang \\ :en) do
    salutation(lang) <> String.capitalize(name) <> @punct
  end

  defp salutation(:en), do: "Hello, "
  defp salutation(:pt), do: "Ola, "
end

Greet.greet("ada")          #=> "Hello, Ada!"
Greet.greet("ada", :pt)     #=> "Ola, Ada!"

IO.inspect(Greet.greet("ada"), label: "greet/1")
IO.inspect(Greet.greet("ada", :pt), label: "greet/2")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
There is no return — the last expression of a clause is its result, and functions are always called as Module.function(args).
Code sample: defmodule Greet do @moduledoc "Docs for the whole module." @punct "!"
  • `alias MyApp.Web.Router` — afterwards call it as just Router; your default tool for referencing other modules
  • `require Logger` — compile in a module's macros before using them; needed before Logger.debug/1
  • `import Integer, only: [is_even: 1]` — pull functions/macros into local scope; use sparingly, explicit calls read better
  • `__MODULE__` — the current module as an atom: used in child specs, docs, and submodule names like defmodule __MODULE__.Sub
  • `@attr "value"` — compile-time constants and tags; @moduledoc, @doc and @spec are just well-known attributes

Immutability & rebinding

Data never mutates: functions return new values, and = only rebinds a name to a different value — every existing value stays intact.

New values, not mutationselixir
map1 = %{name: "Ada", role: :admin}

map2 = Map.put(map1, :role, :user)   # returns a NEW map

map1
#=> %{name: "Ada", role: :admin}     # map1 is untouched
map2
#=> %{name: "Ada", role: :user}

# Idiomatic style: rebind the same name once the old
# value has no further use
user = %{name: "Ada"}
user = Map.put(user, :active, true)

IO.inspect(map1, label: "map1 (untouched)")
IO.inspect(map2, label: "map2 (new)")
IO.inspect(user, label: "rebound")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: map1 = %{name: "Ada", role: :admin} map2 = Map.put(map1, :role, :user) # retu
  • `{:ok, value}` / `{:error, reason}` — prefer matching: {:ok, content} = File.read(path)
  • Do not care about the value? Use _: {:ok, _} = File.close(fd)
  • Same, but self-documenting: _content — still binds, and suppresses the unused-variable warning
  • Strings are immutable too — String.upcase(s) cannot change s in place

Operators

Arithmetic, list and string concatenation, membership — and the difference between value equality == and strict equality ===.

Everyday operatorselixir
iex> 1 + 2 * 3
7
iex> 10 / 4              # / always returns a float
2.5
iex> div(10, 4)          # truncated integer division (rem/2 = remainder)
2
iex> [1, 2] ++ [3, 4]    # list concatenation...
[1, 2, 3, 4]
iex> [1, 2, 3, 4] -- [2, 4]   # ...and difference
[1, 3]
iex> "foo" <> "bar"      # binary (string) concatenation
"foobar"
iex> :elixir in [:erlang, :elixir]   # membership
true
iex> 5 in 1..10          # works on ranges too
true

IO.puts(1 + 2 * 3)
IO.puts(10 / 4)
IO.puts(div(10, 4))
IO.inspect([1, 2] ++ [3, 4])
IO.inspect([1, 2, 3, 4] -- [2, 4])
IO.puts("foo" <> "bar")
IO.inspect(:elixir in [:erlang, :elixir])
IO.inspect(5 in 1..10)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> 1 + 2 * 3 7 iex> 10 / 4 # / always returns a float 2.5 iex> di

== — value equality

iex> 1 == 1.0
true
iex> "a" == "a"
true
iex> :ok == :ok
true
Code sample: iex> 1 == 1.0 true iex> "a" == "a" true iex> :ok == :ok true

=== — value AND type

iex> 1 === 1.0
false
iex> "a" === "a"
true
iex> :ok === :ok
true
Code sample: iex> 1 === 1.0 false iex> "a" === "a" true iex> :ok === :ok true

== compares values across the int/float boundary; === also requires the same type. Prefer === where the distinction matters (list indexes, map keys).

Comparisons < > <= >= follow a total order across all types: number < atom < reference < function < port < pid < tuple < map < list < binary — handy for sorting mixed data, surprising if you forget it.

and/or vs &&/||

and/or/not demand a real boolean on the left and are allowed in guards; &&/||/! accept anything and treat only nil and false as falsy.

Strict — and / or / not

iex> true and :ok
:ok
iex> false or :ok
:ok
iex> not false
true
iex> nil and :ok
** (BadBooleanError) expected a boolean on left-side of "and", got: nil
Code sample: iex> true and :ok :ok iex> false or :ok :ok iex> not false true iex> nil and :ok

Truthy — && / || / !

iex> 1 && "then"         # any non-false/nil is truthy
"then"
iex> nil || "else"
"else"
iex> !nil
true
iex> [1] || :never
[1]
Code sample: iex> 1 && "then" # any non-false/nil is truthy "then" iex> nil || "else"

Only and/or/not compile inside when guards — &&/||/! are rejected there. Both flavours short-circuit.

  • a || b — first truthy value, else b: the default-value idiom opts[:name] || "anon"
  • a && b — b only if a is truthy, else a
  • and/or require a boolean on the left and the VM enforces it with BadBooleanError
  • not x needs a real boolean; !x accepts anything — same result, different strictness

Strings quick start

Double-quoted UTF-8 binaries: interpolate with #{}, concatenate with <>, go multi-line with triple-quote heredocs.

Interpolation, concatenation, heredocselixir
name = "Ada"

"Hello, #{name}!"        # interpolation — runs String.Chars.to_string/1
"Hello, " <> name        # concatenation (binaries only)

sql = """
  SELECT *
  FROM users
  """                    # heredoc: multi-line, indentation trimmed

String.upcase("elixir")  #=> "ELIXIR"
String.length("héllo")   #=> 5   (graphemes)
byte_size("héllo")       #=> 6   (bytes: é is 2 in UTF-8)

IO.inspect("Hello, #{name}!", label: "interpolated")
IO.inspect(String.upcase("elixir"), label: "upcase")
IO.inspect(String.length("héllo"), label: "graphemes")
IO.inspect(byte_size("héllo"), label: "bytes")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: name = "Ada" "Hello, #{name}!" # interpolation — runs String.Chars.to_st

Numbers

Integers and floats with underscores, base prefixes and codepoint syntax; / always floats — use div/2 and rem/2 for integer math.

Literalselixir
iex> 1_000_000             # underscores for readability
1000000
iex> 0b1010                # base 2
10
iex> 0o777                 # base 8
511
iex> 0xFF                  # base 16
255
iex> ?a                    # codepoint of a character
97
iex> 10 / 4                # / is float division
2.5
iex> div(10, 4)            # truncated integer division
2
iex> rem(-10, 3)           # remainder keeps the dividend's sign
-1

IO.inspect([1_000_000, 0b1010, 0o777, 0xFF, ?a])
IO.inspect([10 / 4, div(10, 4), rem(-10, 3)])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> 1_000_000 # underscores for readability 1000000 iex> 0b1010
Parsing, rounding, bit trickselixir
iex> Integer.ceil_div(7, 2)     # division rounded up (since v1.20)
4
iex> Integer.popcount(255)      # count of set bits (since v1.20)
8
iex> Float.round(3.14159, 2)
3.14
iex> Integer.parse("42 steps")  # leading integer + leftover
{42, " steps"}
iex> String.to_integer("42")
42
iex> String.to_float("2.5")     # requires a dot — "2" raises
2.5

IO.inspect([Integer.ceil_div(7, 2), Integer.popcount(255), Float.round(3.14159, 2)])
IO.inspect([Integer.parse("42 steps"), String.to_integer("42"), String.to_float("2.5")])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
div/2 and rem/2 are auto-imported from Kernel; Integer.ceil_div/2 and Integer.popcount/1 are new in v1.20.
Code sample: iex> Integer.ceil_div(7, 2) # division rounded up (since v1.20) 4 iex> Integ

Atoms

Constants whose name is their value — :ok, :error, and every module name. Great as tags, dangerous when created from user input.

Atoms are name-is-the-value constantselixir
iex> :ok                      # an atom's name is its value
:ok
iex> :ok == :error
false
iex> String == :"Elixir.String"   # module aliases are atoms too
true
iex> to_string(:hello)            # atoms convert to strings
"hello"
iex> :"with spaces"               # quoted atoms
:"with spaces"

IO.inspect([:ok == :error, String == :"Elixir.String", to_string(:hello)])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> :ok # an atom's name is its value :ok iex> :ok == :err
Creating atoms from stringselixir
iex> String.to_atom("user_" <> "1")       # creates a NEW atom
:"user_1"
iex> String.to_existing_atom("user_1")    # only if it already exists
:"user_1"

# The one call that cannot fail politely has to be rescued:
try do
  String.to_existing_atom("never_created_123")
rescue
  e in ArgumentError -> IO.puts("rescued: #{Exception.message(e)}")
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> String.to_atom("user_" <> "1") # creates a NEW atom :"user_1" iex> St

Booleans & nil

true, false and nil are atoms with special status; only nil and false are falsy — everything else is truthy.

Truthiness in practiceelixir
iex> true and is_atom(:ok)          # and/or demand booleans
true
iex> nil || false || 0 || "default" # only nil and false are falsy
0
iex> nil && :never_evaluated        # &&/|| accept any value
nil
iex> is_nil(nil)
true
iex> not is_nil(%{})                # there is no nil?/1 in Elixir 1.20
false

IO.inspect([
  true and is_atom(:ok),
  nil || false || 0 || "default",
  nil && :never_evaluated,
  is_nil(nil),
  not is_nil(%{}),
])

try do
  nil and :never_evaluated
rescue
  e -> IO.puts("and demands booleans: " <> Exception.message(e))
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
true, false and nil are atoms under the hood (true == :true), but specs treat them as the distinct types boolean() and nil.
Code sample: iex> true and is_atom(:ok) # and/or demand booleans true iex> nil || fa
  • value || default — fallback for nil/false
  • && and || accept any value on the left; and and or raise BadBooleanError on anything that is not true/false
  • Map.get(map, :key, default) — default for missing keys, not just nil values
  • Map.fetch/2 returns {:ok, v} or :error — distinguishes missing from stored-nil (see Collections)
  • is_nil/1 is guard-safe and idiomatic; avoid == nil in when clauses
  • and/or/not/in/is_nil all work in guards — there is no nil?/1 in Elixir 1.20

Strings vs charlists vs binaries

"hello" is a UTF-8 binary, 'hello' is a charlist (list of codepoints) — and <<...>> binaries are the primitive underneath it all.

String — UTF-8 binary

iex> s = "hello"
"hello"
iex> is_binary(s)
true
iex> s <> "!"               # binaries concatenate with <>
"hello!"
iex> ?h                     # codepoint syntax
104
iex> ?a == 97
true
Code sample: iex> s = "hello" "hello" iex> is_binary(s) true iex> s <> "!" # bi

Charlist — list of codepoints

iex> c = ~c"hello"          # identical to 'hello'
'hello'
iex> is_list(c)
true
iex> c ++ [33]              # lists concatenate with ++
'hello!'
iex> hd(c)                  # each element is a codepoint
104
Code sample: iex> c = ~c"hello" # identical to 'hello' 'hello' iex> is_list(c) true

'hello' and ~c(hello) are the same charlist. Charlists appear at the Erlang edges — some :crypto/:code returns, module-name arguments — and rarely anywhere else. Strings are what you use 99% of the time; <<...>> binaries are the primitive Elixir builds them from.

Sigils overview

Sigils are literal-syntax shorthands: ~s strings, ~c charlists, ~w word lists, ~r regexes, ~D ~T ~N ~U dates — uppercase means fully literal.

The common sigilselixir
iex> ~s(a #{1 + 1} b)          # ~s: string, interpolates
"a 2 b"
iex> ~S(a #{1 + 1} b)          # uppercase: fully literal
"a \#{1 + 1} b"
iex> ~w(see you soon)a         # words; modifier a/s/c = atoms/strings/charlists
[:see, :you, :soon]
iex> ~r/ab+c/                  # regex — see the Regex module
~r/ab+c/
iex> ~c(a charlist)
'charlist'

IO.inspect([~s(a #{1 + 1} b), ~S(a #{1 + 1} b), ~w(see you soon)a, ~c(a charlist)])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> ~s(a #{1 + 1} b) # ~s: string, interpolates "a 2 b" iex> ~S(a #{1
  • Delimiters — any sigil accepts (), [], {}, <>, // or quote pairs; pick the one that needs no escaping inside
  • Heredocs — sigils combine with """ for multi-line text, keeping interpolation rules
  • Date sigils — ~D ~T ~N ~U build date/time structs (next entry)
  • Uppercase = literal — ~S skips both interpolation and escape processing

Dates & time

Four sigils build the date/time structs; Date.shift/3 moves whole calendar units, and to_timeout/1 turns duration lists into milliseconds.

Date, Time, NaiveDateTime, DateTimeelixir
iex> date = ~D[2026-06-03]            # Date struct (no time, no zone)
iex> time = ~T[08:30:00.500]          # Time struct
iex> naive = ~N[2026-06-03T08:30:00]  # NaiveDateTime (no zone)
iex> dt = ~U[2026-06-03T08:30:00Z]    # DateTime, always UTC
iex> Date.shift(date, month: 1)       # calendar-safe shift (since v1.17)
~D[2026-07-03]
iex> DateTime.compare(dt, ~U[2026-12-01T00:00:00Z])
:lt
iex> to_timeout([minute: 5])          # duration -> milliseconds
300000

IO.inspect([
  Date.shift(~D[2026-06-03], month: 1),
  DateTime.compare(~U[2026-06-03T08:30:00Z], ~U[2026-12-01T00:00:00Z]),
  to_timeout([minute: 5]),
])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
DateTime.compare/2 returns :lt | :eq | :gt; to_timeout/1 feeds Process.sleep/1 and timeouts.
Code sample: iex> date = ~D[2026-06-03] # Date struct (no time, no zone) iex> time
  • `Date.shift/3` moves whole units and lands on real dates — Jan 31 plus one month is Feb 28; Date.add/2 just adds days
  • `DateTime.diff/2` returns seconds between two DateTimes; compare when order is all you need
  • `~N` has no timezone — convert with DateTime.from_naive/2 before comparing against a ~U
  • Comparisons need the same kind — Date.compare/2 for dates, DateTime.compare/2 for zoned datetimes; mixing raises

Lists

Singly-linked lists: prepending is O(1), everything else walks the list — so build by prepending and access through Enum.

Head, tail, concat, accesselixir
iex> list = [3, 1, 2]
iex> [head | tail] = list      # head = first element, tail = rest
iex> {head, tail}
{3, [1, 2]}
iex> hd(list)                  # first element (tl/1 = rest)
3
iex> [0 | list]                # prepend: O(1)
[0, 3, 1, 2]
iex> list ++ [4]               # append: O(n) — walks every node
[3, 1, 2, 4]
iex> [1, 2, 3, 4] -- [2, 4]    # difference
[1, 3]
iex> length(list)              # O(n) — counts node by node
3
iex> Enum.at(list, 2)          # O(n) — random access walks the list
2

IO.inspect({head, tail})
IO.inspect([hd(list), length(list), Enum.at(list, 2)])
IO.inspect([0 | list])
IO.inspect(list ++ [4])
IO.inspect([1, 2, 3, 4] -- [2, 4])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> list = [3, 1, 2] iex> [head | tail] = list # head = first element, tai

Prepend — O(1)

def record(event, log) do
  # new node points at the existing list
  [event | log]
end
Code sample: def record(event, log) do # new node points at the existing list [event | lo

Append — O(n)

def record(event, log) do
  # must copy the whole list to reach the end
  log ++ [event]
end
Code sample: def record(event, log) do # must copy the whole list to reach the end log ++

To keep chronological order, prepend while building and call Enum.reverse/1 once at the end — far cheaper than repeated appends.

Lists are linked cons cells, not arrays. Day-to-day list work goes through Enum (eager) and Stream (lazy); the List module covers the recursive, pattern-matching side.

Tuples

Fixed-size, O(1)-access containers — the home of tagged return values like {:ok, value} and {:error, reason}.

elem, put_elem, tuple_sizeelixir
iex> tuple = {:ok, 42}
iex> elem(tuple, 0)
:ok
iex> elem(tuple, 1)
42
iex> put_elem(tuple, 1, 43)   # returns a NEW tuple
{:ok, 43}
iex> tuple_size(tuple)        # O(1) — size is stored, unlike length/1
2
iex> {:error, reason} = {:error, :enoent}
iex> reason
:enoent

IO.inspect({elem(tuple, 0), elem(tuple, 1)})
IO.inspect(put_elem(tuple, 1, 43))
IO.inspect(tuple_size(tuple))
IO.inspect(reason)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> tuple = {:ok, 42} iex> elem(tuple, 0) :ok iex> elem(tuple, 1) 42 iex> put_e
  • Fixed-size containers — adding or removing elements copies; growing data belongs in lists or maps
  • `{:ok, value}` / `{:error, reason}` — the ecosystem-wide return convention; pattern match it, do not ignore it
  • O(1) access — elem/2 and tuple_size/1 are constant-time, unlike list length/1
  • `File.read/1` returns {:ok, binary} | {:error, posix} — the tagged tuple in the wild

Keyword lists

Ordered [key: value] lists of two-tuples: duplicate keys allowed, order preserved — the default for function options and DSLs.

Sugar for tuples of atomselixir
iex> kw = [name: "Ada", role: :admin]
iex> kw == [{:name, "Ada"}, {:role, :admin}]   # sugar for tuples
true
iex> [name: "Ada", name: "Grace"]              # ordered, duplicates allowed
[name: "Ada", name: "Grace"]
iex> Keyword.get(kw, :missing, :default)
:default
iex> Keyword.fetch(kw, :role)
{:ok, :admin}
iex> Keyword.keys(kw)
[:name, :role]

IO.inspect(kw == [{:name, "Ada"}, {:role, :admin}])
IO.inspect([
  Keyword.get(kw, :missing, :default),
  Keyword.fetch(kw, :role),
  Keyword.keys(kw),
])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> kw = [name: "Ada", role: :admin] iex> kw == [{:name, "Ada"}, {:role, :admin
The last-argument sugarelixir
defmodule Client do
  def connect(host, opts \\ []) do
    port = Keyword.get(opts, :port, 4000)
    {host, port}
  end
end

IO.inspect(Client.connect("db.local"))                    # opts = []
IO.inspect(Client.connect("db.local", port: 5433, timeout: 5_000))
# a trailing bracket-less keyword list becomes the LAST argument
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule Client do def connect(host, opts \\ []) do port = Keyword.get(op

Keyword list or map? Keyword list when order matters, keys may repeat, or you are defining options/DSLs. Map for general key-value data with unique keys. Note only the first key of duplicates is visible via kw[:key] — use the Keyword module to see them all.

Maps & MapSet

The general key-value store: %{} with atom-key sugar or => for any keys — plus MapSet for uniqueness and set algebra.

Access patternselixir
iex> m = %{name: "Ada", role: :admin}    # atom keys: shorthand
iex> m.name                              # dot access: only for known atom keys
"Ada"
iex> m[:role]                            # bracket syntax: any key type
:admin
iex> mixed = %{"a" => 1, :b => 2, 3 => "c"}   # => syntax: any key type
iex> Map.fetch!(m, :name)                # raises KeyError when missing
"Ada"
iex> Map.get(m, :missing, "fallback")
"fallback"
iex> Map.fetch(m, :missing)              # :error instead of raising
:error

IO.inspect([m.name, m[:role], Map.fetch!(m, :name)])
IO.inspect([Map.get(m, :missing, "fallback"), Map.fetch(m, :missing)])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Map.fetch/2 distinguishes missing (:error) from stored-nil; Map.get/3 and bracket access collapse both into nil/default.
Code sample: iex> m = %{name: "Ada", role: :admin} # atom keys: shorthand iex> m.name
MapSet — unique memberselixir
iex> set = MapSet.new([1, 2, 2, 3])   # duplicates collapse
iex> MapSet.member?(set, 2)
true
iex> MapSet.union(set, MapSet.new([3, 4]))
#MapSet<[1, 2, 3, 4]>
iex> MapSet.intersection(set, MapSet.new([2, 3, 9]))
#MapSet<[2, 3]>

IO.inspect(MapSet.member?(set, 2))
IO.inspect(MapSet.union(set, MapSet.new([3, 4])))
IO.inspect(MapSet.intersection(set, MapSet.new([2, 3, 9])))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> set = MapSet.new([1, 2, 2, 3]) # duplicates collapse iex> MapSet.member?(

Structs

Maps with a module attached: defstruct fields with defaults, optional compile-time enforcement, and checked update syntax.

defstruct, enforce_keys, updateselixir
defmodule User do
  @enforce_keys [:name]
  defstruct name: nil, role: :member, tags: []
end

defmodule Demo do
  def run do
    user = %User{name: "Ada"}          # missing :name = compile error
    admin = %User{user | role: :admin} # update syntax: existing fields only
    IO.inspect(user.role)
    IO.inspect(admin)
  end
end

Demo.run()
# %User{user | nope: 1}              # unknown key fails to compile (v1.18+)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
%User{user | ...} is the update syntax — it requires an existing struct and only accepts declared fields, verified by the compiler since v1.18.
Code sample: defmodule User do @enforce_keys [:name] defstruct name: nil, role: :member,

Banned since v1.19

defmodule Filter do
  # compile error — regexps are not allowed
  # as struct defaults
  defstruct pattern: ~r/foo/i
end
Code sample: defmodule Filter do # compile error — regexps are not allowed # as struct de

Compile in a constructor

defmodule Filter do
  defstruct pattern: nil

  def new(source) do
    %Filter{pattern: Regex.compile!(source)}
  end
end
Code sample: defmodule Filter do defstruct pattern: nil def new(source) do %Filter{p

A struct default must be a literal value; regexps hold runtime resources, so compile them inside a constructor function like new/1.

  • `@derive Jason.Encoder` — generate a protocol implementation for the struct instead of writing it by hand
  • `@enforce_keys` — make required fields fail at construction time, not deep in runtime code
  • Structs are bare maps — is_map(user) is true; they just add a __struct__ key and compile-time field checking
  • Defaults are compile-time constants — reach for a new/1 function when fields need computing

Ranges — and which collection when?

first..last//step sequences are enumerables — and the fastest way to answer the eternal question: which collection do I reach for?

Rangeselixir
iex> range = 1..10              # inclusive, ascending
iex> 5 in range
true
iex> Enum.map(1..3, &(&1 * 10))
[10, 20, 30]
iex> Enum.sum(1..100//2)        # step: 1, 3, 5, ..., 99
2500
iex> Enum.to_list(10..1//-1)    # descending: explicit negative step
[10, 9, 8, 7, 6, 5, 4, 3, 2, 1]
iex> Range.disjoint?(1..5, 6..10)
true
iex> Range.disjoint?(1..5, 5..10)
false

IO.inspect([
  5 in range,
  Enum.map(1..3, &(&1 * 10)),
  Enum.sum(1..100//2),
  Range.disjoint?(1..5, 6..10),
  Range.disjoint?(1..5, 5..10),
])
IO.inspect(Enum.to_list(10..1//-1))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> range = 1..10 # inclusive, ascending iex> 5 in range true iex>
CollectionOrderedDuplicatesAccessTypical use
Listyesyes[h | t], Enum.at/2 — O(n)iteration, variable-length data
Tupleyesyeselem/2 — O(1)fixed-size records, tagged returns
Keyword listyesyes (keys)kw[:key], Keyword.get/3function options, DSLs
Mapnokeys uniquemap.key, Map.fetch!/2general key-value data
Structnokeys fixedstruct.fieldtyped records, module data
MapSetnonoMapSet.member?/2uniqueness, set algebra

The match operator

= is not assignment: the left side is a pattern matched against the right — it binds variables or raises MatchError.

Patterns bind — or fail loudlyelixir
iex> {a, b} = {1, 2}          # tuples destructure element-wise
iex> a + b
3
iex> [x, y, z] = [1, 2, 3]    # lists destructure positionally
iex> x + y + z
6
iex> {x, x} = {1, 1}          # repeated vars must be equal
iex> %{age: age} = %{age: 30}   # partial match: take the keys you need

IO.inspect(a + b)
IO.inspect(x + y + z)
IO.inspect(age)

# A repeated variable is a match condition, not a rebind:
try do
  {x, x} = {1, 2}
rescue
  e -> IO.puts("rescued: " <> Exception.message(e))
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> {a, b} = {1, 2} # tuples destructure element-wise iex> a + b 3 iex

= is the match operator: Elixir tries to make both sides equal. Fresh variables bind; already-bound variables must match the right side exactly — otherwise the whole expression raises MatchError.

Destructuring

Pull values out of lists, maps, tuples and any nesting — but remember map patterns are partial: extra keys are ignored.

Lists, maps, nestingelixir
iex> [h | t] = [1, 2, 3]
iex> {h, t}
{1, [2, 3]}
iex> %{name: name} = %{name: "Ada", age: 36}   # partial match!
iex> name
"Ada"
iex> %{user: %{name: name}} = %{user: %{name: "Grace", admin: true}}
iex> name
"Grace"
iex> {:ok, [first | _rest]} = {:ok, [1, 2, 3]}
iex> first
1

IO.inspect({h, t})
IO.inspect(name)   # rebound by the nested match above
IO.inspect(first)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> [h | t] = [1, 2, 3] iex> {h, t} {1, [2, 3]} iex> %{name: name} = %{name: "A
  • Head/tail — [h | t] splits any non-empty list; [h | _] just asserts it is non-empty
  • Nested to any depth — %{user: %{address: %{city: city}}}
  • Containers compose — {:ok, [first | _]} matches an ok-tuple wrapping a list
  • String prefixes — "Bearer " <> token splits off a prefix (binary patterns get their own entry)

The pin operator

^var matches against an existing binding instead of creating a new one; _ never binds, _var binds quietly.

Pinning in matcheselixir
iex> expected = 2
iex> {^expected, actual} = {2, 9}     # ^ reads the existing binding
iex> {expected, actual}
{2, 9}

IO.inspect({expected, actual})

# A pin that does not match is a MatchError, not a rebind:
try do
  {^expected, _} = {3, 4}
rescue
  e -> IO.puts("rescued: " <> Exception.message(e))
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> expected = 2 iex> {^expected, actual} = {2, 9} # ^ reads the existing b

Wrong — silently rebinds

status = :pending

# no pin: this creates a NEW binding
{status, other} = {:done, 1}

status
#=> :done  — :pending was lost
Code sample: status = :pending # no pin: this creates a NEW binding {status, other} = {:done

Right — pin with ^

status = :pending

# ^ reads the EXISTING binding
{^status, other} = {:done, 1}
#=> ** (MatchError) — :pending does not match :done
Code sample: status = :pending # ^ reads the EXISTING binding {^status, other} = {:done, 1}

_ matches anything and never binds — repeat it freely in one pattern. _var does bind; it only silences the unused-variable warning, so reading it later is a smell.

Function clauses & case

Multiple clauses try top to bottom and the first match wins — so order specific patterns before general ones, and use when guards to refine.

Multi-clause functions and caseelixir
defmodule Math do
  def sign(n) when n > 0, do: :positive    # guard refines the clause
  def sign(0), do: :zero                   # literal pattern
  def sign(n) when n < 0, do: :negative
  def sign(_), do: {:error, :not_a_number} # catch-all last
end

defmodule Demo do
  def run do
    IO.inspect([Math.sign(3), Math.sign(0), Math.sign(-2), Math.sign("x")])

    case File.read("mix.exs") do
      {:ok, content} -> {:read, byte_size(content)}
      {:error, :enoent} -> :missing
      {:error, reason} -> {:failed, reason}    # cover every shape
    end
    |> IO.inspect(label: "case")
  end
end

Demo.run()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule Math do def sign(n) when n > 0, do: :positive # guard refines the

Wrong — general clause first

def handle({:error, reason}), do: {:log, reason}
def handle({:error, :enoent}), do: :missing

# the second clause can NEVER match —
# the compiler even warns about it
Code sample: def handle({:error, reason}), do: {:log, reason} def handle({:error, :enoent}),

Right — specific first

def handle({:error, :enoent}), do: :missing
def handle({:error, reason}), do: {:log, reason}
def handle(:ok), do: :ok

# no matching clause at all ->
# FunctionClauseError at runtime
Code sample: def handle({:error, :enoent}), do: :missing def handle({:error, reason}), do: {:

Clauses try top to bottom — put specific patterns before general ones. A case that runs out of clauses raises CaseClauseError.

Guards extend patterns with boolean checks: when is_integer(n) and n > 0. Only guard-safe expressions are allowed — is_integer/1, map_size/1, comparisons, and/or/not — no &&/||, no custom functions, no side effects.

Binary patterns

Match binaries byte-for-byte with <<...>>: fixed widths, ::binary tails, ::utf8 codepoints and string prefix patterns.

Sizes, utf8, prefixeselixir
iex> <<a, b>> = <<1, 2>>        # each segment defaults to 8 bits
iex> {a, b}
{1, 2}
iex> <<len, rest::binary>> = <<3, 97, 98, 99>>
iex> {len, rest}
{3, "abc"}
iex> <<a::8, b::16, c::8>> = <<1, 0, 5, 9>>   # bit-sized segments
iex> {a, b, c}
{1, 5, 9}
iex> <<ch::utf8>> = "é"         # one UTF-8 codepoint (2 bytes here)
iex> ch
233
iex> "Bearer " <> token = "Bearer abc123"     # prefix match on strings
iex> token
"abc123"
iex> <<w1::binary-size(2), _::binary>> = "elixir"
iex> w1
"el"

IO.inspect({a, b})
IO.inspect({len, rest})
IO.inspect(ch)
IO.inspect(token)
IO.inspect(w1)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> <<a, b>> = <<1, 2>> # each segment defaults to 8 bits iex> {a, b} {1
Parsing a binary file headerelixir
defmodule PNG do
  @magic <<137, 80, 78, 71, 13, 10, 26, 10>>   # PNG file signature

  def parse(<<@magic::binary, width::32, height::32, _rest::binary>>) do
    {:ok, width, height}
  end

  def parse(_other), do: {:error, :not_a_png}
end

PNG.parse(<<
  137, 80, 78, 71, 13, 10, 26, 10,   # signature
  0, 0, 8, 0,                        # width  = 2048
  0, 0, 4, 80                        # height = 1104
>>)
|> IO.inspect()   #=> {:ok, 2048, 1104}

PNG.parse("not a png")
|> IO.inspect(label: "no match")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
::32 reads a big-endian 32-bit integer and @magic::binary splices the attribute's exact bytes into the pattern. Since v1.20 the type system tracks tuple and binary sizes through matches, so this pattern also proves the data layout to the compiler.
Code sample: defmodule PNG do @magic <<137, 80, 78, 71, 13, 10, 26, 10>> # PNG file signa

Named functions: def & defp

Functions live in modules and are defined as clauses that pattern match top-down — the first matching clause runs. Arity is part of the identity: Text.format/2.

A function is identified by module, name and arity (format/2). Several clauses sharing a name and arity form one function: Elixir tries them in order and runs the first whose pattern matches.

Clauses, privacy, arityelixir
defmodule Text do
  # public — callable from other modules as Text.format/2
  def format(lines, width) do
    lines
    |> Enum.join(" ")
    |> wrap(width)
  end

  # private — visible only inside this module
  defp wrap(string, width) when byte_size(string) <= width, do: string
  defp wrap(string, width), do: String.slice(string, 0, width) <> "…"
end

Text.format(["elixir", "rocks"], 40)
|> IO.inspect()   #=> "elixir rocks"
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
format/2 and format/3 would be two different functions — adding a parameter changes the arity and therefore the identity.
Code sample: defmodule Text do # public — callable from other modules as Text.format/2 de
  • First match wins — order specific clauses before general ones
  • Arity is part of the name — format/2 and format/3 are unrelated functions
  • `defp` is module-private — calling it from another module is a compile error
  • Guards and default arguments (next entries) compose with clauses

Default arguments

The \\ operator (two backslashes) gives a parameter a default and generates one exported function per arity: join/1 falls back to join/2.

A defaulted parameter is filled when the caller omits it. Elixir compiles one clause into several arities, so defaults are visible to callers and docs.

join/1 delegates to join/2elixir
defmodule Join do
  def join(list, sep \\ ", "), do: Enum.join(list, sep)
end

Join.join(["a", "b", "c"])
|> IO.inspect(label: "join/1")

Join.join(["a", "b", "c"], " + ")
|> IO.inspect(label: "join/2")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule Join do def join(list, sep \\ ", "), do: Enum.join(list, sep) end J

Guards: when

since v1.20

Guards are boolean checks after a pattern: when filters clauses, defguard composes your own, and since v1.20 they also feed the type checker.

Patterns match shapes; guards check properties. Guards are restricted to a safe set of expressions so they can never crash or side-effect — a failed guard just skips the clause.

Built-in and custom guardselixir
defmodule Access do
  # defguard composes guards into a reusable name
  defguard is_adult(age) when is_integer(age) and age >= 18

  def check(%{age: age}) when is_adult(age), do: :allowed
  def check(%{age: age}) when is_integer(age), do: :minor
  def check(_person), do: :unknown
end

Access.check(%{age: 21})   #=> :allowed
Access.check(%{age: 9})    #=> :minor
Access.check(%{})          #=> :unknown

IO.inspect([
  Access.check(%{age: 21}),
  Access.check(%{age: 9}),
  Access.check(%{}),
])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule Access do # defguard composes guards into a reusable name defguard
  • Types: is_integer/1, is_float/1, is_binary/1, is_atom/1, is_boolean/1, is_nil/1
  • Collections: is_list/1, is_map/1, is_tuple/1, is_map_key/2, key in map_or_list
  • Logic: and, or, not, ==, !=, <, > — no arbitrary function calls allowed, except those defined by defguard
  • Since v1.20 guards are type-inferred: when is_list(items) and is_integer(n) narrows the clause body's types, so the compiler checks the code inside for free

Anonymous functions

fn args -> body end creates a function value, called with a dot. They support multiple clauses, guards, and close over variables by value.

Anonymous functions are first-class values — store them, pass them, return them. The call syntax is fun.(args): the dot makes the call explicit.

fn, dot calls, closureselixir
double = fn x -> x * 2 end
double.(4)          #=> 8 — call with a DOT

# multiple clauses, matched top-down, guards allowed
classify = fn
  x when x < 0 -> :negative
  0 -> :zero
  _x -> :positive
end
classify.(-5)       #=> :negative

# closures capture by value — data is immutable
n = 10
add_n = fn x -> x + n end
n = 0               # rebinding outside does not change the closure
add_n.(5)           #=> 15

IO.inspect([double.(4), classify.(-5), add_n.(5)])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Forgetting the dot raises UndefinedFunctionError — double(4) would look for a named function double/1.
Code sample: double = fn x -> x * 2 end double.(4) #=> 8 — call with a DOT # multip

The capture operator: &

since v1.19

&Mod.fun/arity grabs a named function as a value; &(&1 + 1) writes tiny anonymous ones with &1 placeholders. Types propagate through captures since v1.19.

& is shorthand for a function value. Use it on an existing function (&Module.fun/arity — Erlang functions too) or build one inline with the &1, &2… placeholders.

Four ways to captureelixir
Enum.map([1, 2, 3], &(&1 * 10))       #=> [10, 20, 30]
add = &(&1 + &2)                      # &2 = second argument
add.(3, 4)                            #=> 7

Enum.reduce([1, 2, 3], 0, &+/2)       # capture operators too
Enum.map(["a", :b, 2], &to_string/1)  #=> ["a", "b", "2"]

upcase = &String.upcase/1             # named function as value
remote = &:erlang.node/0              # Erlang functions included
upcase.("elixir")                     #=> "ELIXIR"

IO.inspect([
  Enum.map([1, 2, 3], &(&1 * 10)),
  add.(3, 4),
  Enum.reduce([1, 2, 3], 0, &+/2),
  Enum.map(["a", :b, 2], &to_string/1),
  upcase.("elixir"),
])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Since v1.19 the compiler propagates types through captures — Enum.map(names, &String.upcase/1) is fully type-checked end to end.
Code sample: Enum.map([1, 2, 3], &(&1 * 10)) #=> [10, 20, 30] add = &(&1 + &2)

Higher-order functions & then/2

Functions are values: take them as arguments, return them from factories, and use then/2 to keep a pipeline flowing when the value is not the first argument.

Any function that accepts or returns functions is higher-order — Enum.map/2, Enum.reduce/3, Kernel.then/2. When a pipeline step does not take the piped value first, or needs branching mid-pipe, then/2 inserts it cleanly.

Functions as arguments and factorieselixir
transform = fn fun, x -> fun.(fun.(x)) end
transform.(&(&1 * 3), 2)          #=> 18

# then/2 threads a value through a single function
port =
  System.get_env("ELIXIR_CHEATS_PORT")   # unset -> nil
  |> then(fn
    nil -> "4000"
    port -> port
  end)
  |> String.to_integer()

# functions building functions — closures as factories
prefix = fn tag -> fn msg -> "[#{tag}] #{msg}" end end
log = prefix.("info")
log.("started")                   #=> "[info] started"

IO.inspect(port)
IO.inspect(log.("started"))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: transform = fn fun, x -> fun.(fun.(x)) end transform.(&(&1 * 3), 2) #=>
  • then(value, fun) simply calls fun.(value) — ideal mid-pipe
  • The & capture plus higher-order Enum functions cover most everyday composition
  • Closures (not curry/apply) are the idiomatic way to build configurable functions

if / else / unless / cond

since v1.17

if handles one condition, cond tries many boolean branches — and both are expressions you can assign. unless is soft-deprecated since v1.17.

There are no statements — if, cond and even defmodule return values, so bind the result. Only nil and false are falsy; everything else (including 0 and the empty string) is truthy.

Branching as an expressionelixir
n = 7

parity =
  if rem(n, 2) == 0 do
    "even"
  else
    "odd"
  end
#=> "odd"

grade =
  cond do
    n >= 90 -> :a
    n >= 80 -> :b
    n >= 70 -> :c
    true -> :f            # true acts as the catch-all
  end
#=> :f

IO.inspect([parity, grade])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: n = 7 parity = if rem(n, 2) == 0 do "even" else "odd" end #=> "od

unless — soft-deprecated

# warns since v1.17
unless Enum.empty?(errors) do
  IO.puts("found problems!")
end
Code sample: # warns since v1.17 unless Enum.empty?(errors) do IO.puts("found problems!") e

if not — idiomatic

if not Enum.empty?(errors) do
  IO.puts("found problems!")
end
Code sample: if not Enum.empty?(errors) do IO.puts("found problems!") end

unless emits a deprecation warning since v1.17; the migration is mechanical — unless x becomes if not x.

case — match & dispatch

since v1.20

Match one value against clauses top-down, with guards per clause. Since v1.20 the compiler narrows types across clauses — handle nil first and it is gone later.

case pattern-matches the value against each clause in order; a guard can refine any clause. It replaces whole chains of if/else when the decision is about a value's shape.

Clauses + guards, first match winselixir
resp = {201, %{id: 42}}

outcome =
  case resp do
    {status, _body} when status in 200..299 -> {:ok, :created}
    {404, _body} -> {:error, :not_found}
    {status, _body} when status >= 500 -> {:error, :server}
    _other -> {:error, :unknown}     # catch-all keeps this total
  end
#=> {:ok, :created}

IO.inspect(outcome)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: resp = {201, %{id: 42}} outcome = case resp do {status, _body} when statu
Type narrowing across clauses (v1.20)elixir
case System.get_env("ELIXIR_CHEATS_PORT") do   # unset -> nil
  nil -> 4000                      # nil is handled first...
  port -> String.to_integer(port)  # ...so port is a binary here
end
|> IO.inspect()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Since v1.20 the compiler narrows across clauses: matching nil removes it from later clauses, so String.to_integer(port) is proven sound — no annotations needed.
Code sample: case System.get_env("ELIXIR_CHEATS_PORT") do # unset -> nil nil -> 4000

with — the happy path

Chain steps that each return {:ok, value}; <- pattern-matches and binds. The first non-matching step short-circuits — and returns its raw value.

with is a pipeline of pattern matches. <- takes the right side apart and binds variables; plain expressions run in order. Add else clauses to normalize failures.

Chain matches, normalize failures with elseelixir
defmodule Profile do
  def city(map) do
    with {:ok, user} <- Map.fetch(map, :user),
         {:ok, address} <- Map.fetch(user, :address),
         {:ok, city} <- Map.fetch(address, :city) do
      {:ok, city}
    else
      :error -> {:error, :missing_key}   # Map.fetch fails with :error
      other -> other                     # any other miss, passed through
    end
  end
end

Profile.city(%{user: %{address: %{city: "Tokyo"}}})
#=> {:ok, "Tokyo"}

IO.inspect(Profile.city(%{user: %{address: %{city: "Tokyo"}}}))
IO.inspect(Profile.city(%{}))   # the else branch normalises the miss
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule Profile do def city(map) do with {:ok, user} <- Map.fetch(map, :

for comprehensions

since v1.19

Generate, filter and collect in one declarative pass over any Enumerable — with into: and reduce: to choose the destination. Generator types are checked since v1.19.

Generators, filters, into:, reduce:elixir
# generator + filter
for n <- 1..5, rem(n, 2) == 1, do: n * n
#=> [1, 9, 25]

# multiple generators nest like loops
for x <- [1, 2], y <- ~w(a b), do: {x, y}
#=> [{1, "a"}, {1, "b"}, {2, "a"}, {2, "b"}]

# pattern filters skip non-matching elements
for {:ok, value} <- [{:ok, 1}, :error, {:ok, 3}], do: value
#=> [1, 3]

# into: collects into any collectable — binaries, MapSets, tuples...
for s <- ["ab", "cd"], into: "", do: s
#=> "abcd"

for x <- [1, 2, 2, 3], into: MapSet.new(), do: x
#=> MapSet.new([1, 2, 3])

# reduce: turns the comprehension into an accumulation
for n <- 1..100, reduce: 0 do
  acc -> if rem(n, 3) == 0, do: acc + n, else: acc
end
#=> 1683

IO.inspect(for n <- 1..5, rem(n, 2) == 1, do: n * n)
IO.inspect(for x <- [1, 2], y <- ~w(a b), do: {x, y})
IO.inspect(for {:ok, value} <- [{:ok, 1}, :error, {:ok, 3}], do: value)
IO.inspect(for s <- ["ab", "cd"], into: "", do: s)
IO.inspect(for x <- [1, 2, 2, 3], into: MapSet.new(), do: x)

IO.inspect(
  for n <- 1..100, reduce: 0 do
    acc -> if rem(n, 3) == 0, do: acc + n, else: acc
  end
)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: # generator + filter for n <- 1..5, rem(n, 2) == 1, do: n * n #=> [1, 9, 25] #
  • Generators work over any Enumerable — lists, ranges, maps (as {key, value} pairs), MapSet, File.stream! output
  • Filters are boolean expressions or patterns; a failing pattern quietly skips the element
  • Since v1.19 the compiler type-checks that a generator actually implements the Enumerable protocol — wrong types fail at compile time
  • for without into: always returns a list; into: picks the collection type

Which construct when?

Same input, different shapes of decision — pick by what you are testing: a condition, a value's shape, a chain of matches, or a collection.

The constructs overlap on purpose — Elixir lets you choose the one that states the intent most directly. When in doubt: conditions → if/cond, shapes → case, chains → with, collections → for.

ConstructUse whenExample hint
if / elseone condition, two branchesif valid?, do: save(user)
unlessnothing new — soft-deprecated since v1.17, write if notif not valid?, do: retry()
condseveral boolean conditions, first truthy winsscore thresholds → letter grade
casematch one value against patterns (+ guards){:ok, _} vs {:error, _}
witha pipeline of matches on the happy pathparse → validate → save
fortransform / filter / accumulate an Enumerablefor n <- 1..10, do: n * 2
try/rescuehandle raised exceptions at boundariessee Errors & Exceptions

Enum essentials

since v1.20

Enum runs eagerly over any Enumerable and always returns new data — inputs are never mutated. A dozen functions cover most day-to-day data shaping.

Every Enum function takes the data as the first argument and returns a new value. Start with map/filter/reduce, then let sort_by, group_by and friends replace hand-rolled loops. Since v1.20 Enum.min_max/2 accepts a sorter.

The core iteration setelixir
orders = [
  %{id: 1, total: 30, user: "ada"},
  %{id: 2, total: 120, user: "grace"},
  %{id: 3, total: 60, user: "linus"}
]

Enum.map(orders, & &1.total)              #=> [30, 120, 60]
Enum.filter(orders, &(&1.total > 50))     #=> orders 2 and 3
Enum.find(orders, &(&1.total > 100))      #=> %{id: 2, ...}
Enum.count(orders, &(&1.total > 50))      #=> 2
Enum.any?(orders, &(&1.total > 100))      #=> true
Enum.all?(orders, &(&1.total > 0))        #=> true
Enum.reduce(orders, 0, &(&1.total + &2))  #=> 210

IO.inspect(Enum.map(orders, & &1.total))
IO.inspect(Enum.count(orders, &(&1.total > 50)))
IO.inspect(Enum.find(orders, &(&1.total > 100)))
IO.inspect(Enum.reduce(orders, 0, &(&1.total + &2)))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: orders = [ %{id: 1, total: 30, user: "ada"}, %{id: 2, total: 120, user: "gra
Shaping and splittingelixir
orders = [
  %{id: 1, total: 30, user: "ada"},
  %{id: 2, total: 120, user: "grace"},
  %{id: 3, total: 60, user: "linus"}
]

Enum.sort_by(orders, & &1.total, :desc)   #=> biggest first
Enum.group_by(orders, & &1.user)          #=> %{"ada" => [...], ...}
Enum.split_with(orders, &(&1.total > 50)) #=> {big, small}
Enum.chunk_every([1, 2, 3, 4, 5], 2)      #=> [[1, 2], [3, 4], [5]]
Enum.frequencies(~w(a b a c))             #=> %{"a" => 2, "b" => 1, "c" => 1}

# min_by/2 and max_by/2 pick by a key; min_max/1 works on ordered values
Enum.min_by(orders, & &1.total)         #=> the cheapest order
Enum.max_by(orders, & &1.total)         #=> the priciest order
Enum.min_max([10, 20, 30])              #=> {10, 30}
Enum.sum([10, 20, 30])                    #=> 60

IO.inspect(Enum.map(Enum.sort_by(orders, & &1.total, :desc), & &1.id))
IO.inspect(Map.keys(Enum.group_by(orders, & &1.user)))
IO.inspect(Enum.chunk_every([1, 2, 3, 4, 5], 2))
IO.inspect(Enum.frequencies(~w(a b a c)))
IO.inspect(Enum.map([Enum.min_by(orders, & &1.total), Enum.max_by(orders, & &1.total)], & &1.id))
IO.inspect(Enum.min_max([10, 20, 30]))
IO.inspect(Enum.sum([10, 20, 30]))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: orders = [ %{id: 1, total: 30, user: "ada"}, %{id: 2, total: 120, user: "gra

The pipe operator: |>

|> inserts the previous result as the FIRST argument of the next call — data transformation reads top to bottom. Since v1.20 dbg shows every intermediate step.

x |> f(a) is exactly f(x, a). Pipelines shine when each step takes the data first — which is why Enum functions are designed that way. Steps that need the value elsewhere take then/2 or a wrapper fn.

A pipeline reads like the problemelixir
"data.csv"
|> File.read!()
|> String.split("\n")
|> Enum.map(&String.trim/1)
|> Enum.reject(&(&1 == ""))
|> Enum.count()
|> IO.inspect(label: "lines")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Same code without pipes: Enum.count(Enum.reject(Enum.map(String.split(File.read!("data.csv"), "\n"), &String.trim/1), &(&1 == ""))) — nested, inside-out, unreadable.
Code sample: "data.csv" |> File.read!() |> String.split("\n") |> Enum.map(&String.trim/1) |>
Debugging pipes and non-first-arg stepselixir
[3, 1, 2]
|> Enum.sort()
|> dbg()          # since v1.20: prints EVERY step with its result
|> Enum.sum()
#=> 6

port =
  System.get_env("ELIXIR_CHEATS_PORT")   # unset -> nil
  |> then(fn
    nil -> "4000"
    port -> port
  end)
  |> String.to_integer()

IO.inspect(port)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
dbg/1 returns its argument, so it can sit anywhere in the pipe — since v1.20 it prints each surrounding step with its intermediate result. then/2 covers branching and value-not-first steps.
Code sample: [3, 1, 2] |> Enum.sort() |> dbg() # since v1.20: prints EVERY step with

Streams — lazy & composable

Stream functions build a lazy pipeline: nothing runs until an Enum function forces it. One element flows through at a time — constant memory, even for infinite data.

Laziness and constant-memory file processingelixir
# lazy: nothing runs until Enum.take pulls values
1..1_000_000_000
|> Stream.map(&(&1 * 3))
|> Stream.filter(&(rem(&1, 2) == 0))
|> Enum.take(3)
#=> [6, 12, 18]

# stream a large file line by line — constant memory
File.stream!("logs/app.log")
|> Stream.map(&String.trim_trailing(&1, "\n"))
|> Stream.filter(&String.contains?(&1, "ERROR"))
|> Enum.take(5)
|> IO.inspect(label: "errors")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: # lazy: nothing runs until Enum.take pulls values 1..1_000_000_000 |> Stream.map
  • Enum: eager — each step builds a full intermediate list; fastest for small, in-memory data
  • Stream: lazy — one element flows through the whole pipeline; best for huge, infinite or IO-backed data
  • Three composed Enum steps walk and copy the data three times; one Stream walks it once
  • Since v1.20, Stream.cycle/1 raises on an empty list instead of misbehaving deep inside a later run

Comprehensions vs Enum pipelines

Two styles, one result: for walks the data once and reads like a spec; Enum pipelines compose eager steps. Pick whichever reads better.

for and Enum pipelines overlap heavily. Comprehensions avoid intermediate lists and handle pattern filters naturally; Enum pipelines are easier to extend step by step and to split into named functions.

for — single pass

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

Enum — composable steps

squares =
  1..10
  |> Enum.filter(&(rem(&1, 2) == 0))
  |> Enum.map(&(&1 * &1))
#=> [4, 16, 36, 64, 100]
Code sample: squares = 1..10 |> Enum.filter(&(rem(&1, 2) == 0)) |> Enum.map(&(&1 * &1))

Same result, two styles. Rule of thumb: reach for for when one pass over patterns reads clearest, for Enum when the steps deserve names or reuse.

Essential Enum functions

The daily-driver table: what each function does and what comes back. Everything here works on any Enumerable and returns new data.

Arities shown are the common ones — most functions have a variant with an extra argument (a sorter, a default, a :nofile-style option). Bookmark h Enum in IEx for the full set.

FunctionWhat it doesReturns
Enum.map/2apply a function to every elementnew list
Enum.filter/2keep elements where the function is truthynew list
Enum.reduce/3fold every element into one accumulatoraccumulator
Enum.each/2run a function for side effects:ok
Enum.find/2first element where the function is truthyelement or nil
Enum.any?/2 / all?/2do some / all elements pass?boolean
Enum.sort_by/3order by a computed key, asc or descnew list
Enum.group_by/2bucket elements by a computed keymap of key → list
Enum.split_with/2partition into matching and the rest{matches, rest}
Enum.chunk_every/2slice into fixed-size groupslist of lists
Enum.frequencies/1count occurrences of each elementmap of element → count
Enum.min_max/2smallest and largest by a sorter{min, max} — sorter since v1.20

Body recursion over lists

There are no loops in Elixir: a recursion is clauses — a base case for [] and a recursive case for [head | tail].

Data is immutable and there is no loop syntax — instead, define the function in clauses: one for the smallest input (the base case) and one that reduces the problem ([head | tail]) and calls itself. Immutability guarantees the list shrinks every call, so termination comes from the data.

Sum and map, by handelixir
defmodule MyList do
  # base case: empty list — stops the recursion
  def sum([]), do: 0

  # recursive case: peel the head, recurse on the tail
  def sum([head | tail]), do: head + sum(tail)

  def square([]), do: []
  def square([head | tail]), do: [head * head | square(tail)]
end

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

IO.inspect(MyList.sum([1, 2, 3, 4]))
IO.inspect(MyList.square([1, 2, 3, 4]))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule MyList do # base case: empty list — stops the recursion def sum([]

Tail calls & accumulators

Move the work into an accumulator so the recursive call is the LAST operation — BEAM tail-call optimization then runs it in constant stack space.

In body recursion each stack frame waits for the recursive result. Make the recursive call the last thing the function does and carry partial results in an accumulator — the BEAM then reuses the single frame, giving O(1) stack regardless of input size.

Body recursion — stack grows

defmodule Body do
  def len([]), do: 0
  def len([_ | tail]), do: 1 + len(tail)
end

# 1 + (1 + (1 + ...)) — every frame waits for
# the next call, so the stack grows with the list
Code sample: defmodule Body do def len([]), do: 0 def len([_ | tail]), do: 1 + len(tail)

Tail recursion — constant stack

defmodule Tail do
  def len(list), do: go(list, 0)

  defp go([], acc), do: acc
  defp go([_ | tail], acc), do: go(tail, acc + 1)
end

# acc carries the result; the recursive call IS
# the last operation, so BEAM reuses the frame
Code sample: defmodule Tail do def len(list), do: go(list, 0) defp go([], acc), do: acc
The Enum.reverse/1 idiomelixir
defmodule Doubler do
  def run(list), do: go(list, [])

  defp go([], acc), do: Enum.reverse(acc)      # restore order
  defp go([head | tail], acc), do: go(tail, [head * 2 | acc])
end

Doubler.run([1, 2, 3])  #=> [2, 4, 6]

IO.inspect(Doubler.run([1, 2, 3]))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Prepending to the accumulator is O(1); appending is O(n) — so accumulate reversed and finish with the classic Enum.reverse/1.
Code sample: defmodule Doubler do def run(list), do: go(list, []) defp go([], acc), do:

reduce as universal recursion

An accumulator threaded through clauses is exactly Enum.reduce — map, filter, sum and friends are all special cases of it.

Before hand-writing recursive clauses over a collection, ask: is this an accumulator being threaded through? Then Enum.reduce/3 states it once — no base case to forget, and the intent is obvious to every Elixirist.

The same recursions, declarativelyelixir
Enum.reduce([1, 2, 3, 4], 0, &+/2)
#=> 10 — the recursive sum, one line

squares =
  [1, 2, 3, 4]
  |> Enum.reduce([], &[&1 * &1 | &2])
  |> Enum.reverse()
#=> [1, 4, 9, 16]

IO.inspect(Enum.reduce([1, 2, 3, 4], 0, &+/2))
IO.inspect([1, 2, 3, 4] |> Enum.reduce([], &[&1 * &1 | &2]) |> Enum.reverse())
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Writing custom recursion is still the right tool for multiple shapes, early exits, or traversals reduce cannot express — but it is now a deliberate choice.
Code sample: Enum.reduce([1, 2, 3, 4], 0, &+/2) #=> 10 — the recursive sum, one line squares

Recursion over multiple shapes

Clauses can dispatch on several shapes at once — multiple base cases, nested structures, guards — but every path needs a terminating clause.

Base cases are where correctness lives — enumerate them first (0, 1, [], missing keys), then the shapes that recurse. Multiple base cases and nested data fall out of the same clause style.

Two base cases + nested shapeselixir
defmodule Nested do
  # multiple base cases
  def fib(0), do: 0
  def fib(1), do: 1
  def fib(n) when is_integer(n) and n > 1, do: fib(n - 1) + fib(n - 2)

  # recursion over nested shapes
  def flatten([]), do: []
  def flatten([head | tail]) when is_list(head),
    do: flatten(head) ++ flatten(tail)
  def flatten([head | tail]), do: [head | flatten(tail)]
end

Nested.flatten([1, [2, [3, 4]], 5])  #=> [1, 2, 3, 4, 5]

IO.inspect(Enum.map(0..10, &Nested.fib/1))
IO.inspect(Nested.flatten([1, [2, [3, 4]], 5]))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule Nested do # multiple base cases def fib(0), do: 0 def fib(1), do

Error values vs bang functions

Expected failures come back as {:error, reason}; bang (!) variants raise instead. try/rescue is for boundary code — the anatomy comes in the next entry.

Elixir splits failure into two styles: values for failures the caller should handle, exceptions for bugs it should not. Choose the style by asking: would a reasonable caller want to recover from this? (Error-handling exercises in exlings: 060–064.)

Two failure styles side by sideelixir
user = %{name: "Ada", role: :admin}

Map.fetch(user, :age)      #=> :error — absence is a value
Map.fetch(user, :name)     #=> {:ok, "Ada"}

Map.fetch!(user, :role)    #=> :admin — bang raises when missing
File.read("config.toml")   #=> {:ok, data} | {:error, :enoent}
File.read!("config.toml")  #=> data, or ** (File.Error)

IO.inspect(Map.fetch(user, :age))
IO.inspect(Map.fetch(user, :name))
IO.inspect(Map.fetch!(user, :role))
IO.inspect(File.read("config.toml"))

try do
  File.read!("missing.toml")
rescue
  e -> IO.puts("rescued: " <> Exception.message(e))
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: user = %{name: "Ada", role: :admin} Map.fetch(user, :age) #=> :error — abs
  • Expected failure → {:ok, result} / {:error, reason} — callers match on it
  • Bug / impossible state → raise — let it crash (see let-it-crash below)
  • Bang variants raise on failure: Map.fetch!/2, File.read!/1, JSON.decode!/1
  • Non-bang return tagged tuples: Map.fetch/2, File.read/1, JSON.decode/1
  • try/rescue is for crossing boundaries — convert to a response or log and re-raise

raise, defexception & try anatomy

raise throws an exception struct; defexception defines your own. try has the full anatomy: rescue matches exception types, else handles success, after always cleans up.

An exception is just a struct. defexception defines one with fields and a message formatter; raise builds (or re-raises) it. Rescue clauses pattern-match on the exception type and bind the struct.

Custom exceptionselixir
defmodule PaymentError do
  defexception [:message, :reason]

  @impl true
  def message(%__MODULE__{message: msg, reason: reason}) do
    "#{msg} (reason: #{inspect(reason)})"
  end
end

# raise "simple message"                # RuntimeError
# raise PaymentError,
#   message: "declined",
#   reason: :insufficient_funds
#=> ** (PaymentError) declined (reason: :insufficient_funds)

try do
  raise "simple message"
rescue
  e -> IO.puts("RuntimeError: " <> Exception.message(e))
end

try do
  raise PaymentError, message: "declined", reason: :insufficient_funds
rescue
  e in PaymentError -> IO.puts("PaymentError: " <> Exception.message(e))
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule PaymentError do defexception [:message, :reason] @impl true def
try / rescue / else / after — full anatomyelixir
result =
  try do
    {:ok, JSON.decode!(File.read!("settings.json"))}
  rescue
    e in File.Error -> {:error, {:file, e.reason}}
    e in JSON.DecodeError -> {:error, {:json, Exception.message(e)}}
  else
    {:ok, data} -> {:ok, data["theme"]}
  after
    IO.puts("attempt finished")     # always runs — cleanup
  end

IO.inspect(result)
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Order is do → rescue → else → after. else pattern-matches the value when nothing raised; after runs on success, raise, throw and exit alike.
Code sample: result = try do {:ok, JSON.decode!(File.read!("settings.json"))} rescue

throw / catch & exit

throw is an escape hatch for libraries that must abort deep inside a computation — Enum uses it to stop enumerating early. exit/1 terminates a process on purpose. App code should use neither.

throw sends any value up to the nearest matching catch; exit/1 ends the current process with a reason. Linked processes can trap exits and receive {:EXIT, pid, reason} messages instead of dying.

catch matches the thrown value; exit ends a processelixir
# catch pattern-matches the thrown value
try do
  throw(:early)
catch
  :early -> :caught_it
end
#=> :caught_it

# exit/1 ends the current process on purpose
Process.flag(:trap_exit, true)   # parent survives; gets :EXIT messages
pid = spawn_link(fn -> exit(:boom) end)

receive do
  {:EXIT, ^pid, :boom} -> :child_exited
end
#=> :child_exited

IO.inspect(
  try do
    throw(:early)
  catch
    :early -> :caught_it
  end
)

Process.flag(:trap_exit, true)   # parent survives; gets :EXIT messages
pid = spawn_link(fn -> exit(:boom) end)

receive do
  {:EXIT, ^pid, :boom} -> IO.puts("child_exited")
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: # catch pattern-matches the thrown value try do throw(:early) catch :early -

OK — return error values

def parse_age(params) do
  case Integer.parse(params["age"]) do
    {age, ""} -> {:ok, age}
    _ -> {:error, :invalid_age}
  end
end
Code sample: def parse_age(params) do case Integer.parse(params["age"]) do {age, ""} ->

Anti-pattern — throw for control flow

def parse_age(params) do
  case Integer.parse(params["age"]) do
    {age, ""} -> {:ok, age}
    _ -> throw({:error, :invalid_age})  # every caller must catch
  end
end
Code sample: def parse_age(params) do case Integer.parse(params["age"]) do {age, ""} ->

throw skips normal returns: one forgotten catch wrapper turns a handled error into a nocatch crash. Keep {:error, reason} for expected failures.

The let-it-crash philosophy

Expected failures are values, bugs are crashes: let the process die and let a supervisor restart it in a known-good state.

The BEAM draws a hard line that most languages blur: failures the caller can handle are values, and everything else is a bug that should crash — loudly, early, and in a process whose state dies with it. Don't defend against bugs with rescue-all; isolate them and restart clean.

  • Expected (user typo, missing file) → return {:error, reason} and let callers decide
  • Bug (impossible clause, corrupt state) → raise — supervisors restart, telemetry reports
  • One process crashing does not kill the node — links and supervisors scope the blast radius
  • Convert at boundaries: web plugs turn crashes into 500s, GenServer.call turns them into exits
  • Restart strategies (:one_for_one, :rest_for_one) live in the supervision tree — see Processes & OTP

v1.20 type checker × error handling

since v1.20

The gradual type checker understands the error conventions: it warns when a call can be proven to always raise, flags dead clauses, and proves error paths total — no annotations.

Since v1.20 inference of every construct means the compiler reasons about failure itself: which clauses are reachable, which calls can only raise, whether the error path is handled. Broken error handling becomes a compile-time warning instead of a 3 a.m. pager.

Provable bugs, found for freeelixir
defmodule Users do
  def name!(user) do
    Map.fetch!(user, :name)
  end

  def role(user) do
    case Map.fetch(user, :role) do
      {:ok, role} -> role
      :error -> :guest
      other -> other               # v1.20: dead clause warning
    end
  end
end

# since v1.20 the compiler proves this call can only raise:
#   Users.name!(%{})   # warning: %{} can never have :name
Users.name!(%{name: "Ada"})        #=> "Ada"

IO.inspect(Users.role(%{role: :admin}))
IO.inspect(Users.name!(%{name: "Ada"}))

try do
  Users.name!(%{})
rescue
  e -> IO.puts("rescued: " <> Exception.message(e))
end
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Map.fetch! needs a map with the :name key; %{} is disjoint from that type, so the call is flagged before it ever runs.
Code sample: defmodule Users do def name!(user) do Map.fetch!(user, :name) end def
  • Disjoint-type violations — arguments that can never satisfy the function, like Map.fetch!/2 on a proven-missing key
  • Dead code — clauses after a catch-all and branches after a raise are flagged as redundant
  • Narrowing through error paths — handling the nil case removes nil from later clauses, so the happy path is provably safe
  • Zero annotations — warnings appear during a plain mix compile

spawn, send & receive

Processes are spawned from functions, exchange immutable messages, and each has a mailbox. State lives in a recursive loop — the argument is the state.

Spawn a process and talk to itelixir
parent = self()

_pid = spawn(fn ->
  receive do                    # wait for a message in the mailbox
    {:hello, from} -> send(from, :hi)
  after
    5_000 -> :timeout           # give up after 5 seconds
  end
end)

send(_pid, {:hello, parent})    # the child receives it and answers the sender
receive do
  :hi -> :got_a_reply
end
|> IO.inspect()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
send/2 never blocks — messages are copied into the mailbox. receive scans the mailbox top-down; unhandled messages stay in the mailbox.
Code sample: parent = self() _pid = spawn(fn -> receive do # wait for a
The state-in-argument loop patternelixir
defmodule Counter do
  def loop(count) do            # the argument is the process state
    receive do
      {:incr, by} -> loop(count + by)
      :stop -> :ok              # no recursive call = process exits
      {:get, caller} ->
        send(caller, {:count, count})
        loop(count)
    end
  end
end

counter = spawn(Counter, :loop, [0])
send(counter, {:incr, 2})
send(counter, {:get, self()})
receive do {:count, n} -> n end  #=> 2
|> IO.inspect()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Processes are extremely cheap: heaps of hundreds of thousands are normal, each with its own garbage collection. Crashing one never corrupts another.
Code sample: defmodule Counter do def loop(count) do # the argument is the proce

Task & Agent — lightweight OTP

Task = one-off async computation with error propagation; Agent = a tiny state holder. Both are GenServers under the hood — no hand-written receive loops.

Task — async/await and concurrent streamselixir
defmodule Work do
  def heavy(n), do: n * n
end

task = Task.async(fn -> Work.heavy(1) end)   # spawn + monitor + link
result = Task.await(task, 5_000)             # raises on crash or timeout
result
|> IO.inspect(label: "await")

# run one function over many items, bounded concurrency:
1..10
|> Task.async_stream(&Work.heavy(&1),
  max_concurrency: System.schedulers_online(),
  timeout: 10_000
)
|> Enum.to_list()                     # [{:ok, _} | {:exit, _}, ...]
|> Enum.map(fn {:ok, value} -> value end)
|> IO.inspect(label: "stream")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Task.async/await links the task to the caller — if it crashes, the caller crashes too. That is the good kind of failure in Elixir.
Code sample: defmodule Work do def heavy(n), do: n * n end task = Task.async(fn -> Work.he
Agent — background state without boilerplateelixir
{:ok, agent} = Agent.start_link(fn -> %{} end)

Agent.update(agent, &Map.put(&1, :visits, 1))
Agent.get(agent, &Map.get(&1, :visits))         #=> 1

Agent.get_and_update(agent, fn state ->
  # return {value_to_return, new_state}
  {Map.get(state, :visits, 0),
   Map.update(state, :visits, 1, &(&1 + 1))}
end)
|> IO.inspect(label: "get_and_update")

Agent.get(agent, &Map.get(&1, :visits))
|> IO.inspect(label: "after")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
For state that must survive crashes or needs access policies, graduate to a GenServer supervised by your tree.
Code sample: {:ok, agent} = Agent.start_link(fn -> %{} end) Agent.update(agent, &Map.put(&1,

GenServer — the workhorse

use GenServer + client API + callbacks: init/1, handle_call/3 (sync), handle_cast/2 (async). Register with a name so callers need no PID.

Minimal complete GenServerelixir
defmodule Stack do
  use GenServer

  ## Client API — runs in the CALLER process
  def start_link(initial),
    do: GenServer.start_link(__MODULE__, initial, name: __MODULE__)

  def push(item), do: GenServer.cast(__MODULE__, {:push, item})
  def pop, do: GenServer.call(__MODULE__, :pop)

  ## Callbacks — run in the SERVER process
  @impl true
  def init(initial), do: {:ok, initial}

  @impl true
  def handle_call(:pop, _from, [head | tail]), do: {:reply, head, tail}
  def handle_call(:pop, _from, []), do: {:reply, nil, []}

  @impl true
  def handle_cast({:push, item}, state), do: {:noreply, [item | state]}
end
name: __MODULE__ registers a local name (one per node); {:global, term} or a {:via, Registry, {...}} tuple for wider scopes.
Code sample: defmodule Stack do use GenServer ## Client API — runs in the CALLER process
Using itelixir
{:ok, _} = Stack.start_link([1, 2])
Stack.push(3)
Stack.pop()   #=> 3
Stack.pop()   #=> 2
Code sample: {:ok, _} = Stack.start_link([1, 2]) Stack.push(3) Stack.pop() #=> 3 Stack.pop(
  • call — synchronous, backpressure, timeout (default 5000ms); use for reads and anything the caller must wait on
  • cast — fire-and-forget; no confirmation, no backpressure — use sparingly
  • `{:reply, reply, state}` / `{:noreply, state}` — answer now, or later via GenServer.reply/2
  • `{:stop, reason, state}` — shut down cleanly; your Supervisor will restart it

Supervisors — let it crash

A Supervisor starts children, restarts them when they die, and gives up (taking the whole tree down) when restarts exceed the intensity limit.

A supervision tree rootelixir
defmodule MyApp.Supervisor do
  use Supervisor

  def start_link(arg),
    do: Supervisor.start_link(__MODULE__, arg, name: __MODULE__)

  @impl true
  def init(_arg) do
    children = [
      MyApp.Cache,                        # child_spec/1 from use GenServer
      {MyApp.Worker, name: MyApp.Worker}  # module + start argument
    ]

    Supervisor.init(children,
      strategy: :one_for_one,
      max_restarts: 3,     # default: 3 restarts...
      max_seconds: 5       # ...per 5 seconds, else the supervisor aborts
    )
  end
end
Code sample: defmodule MyApp.Supervisor do use Supervisor def start_link(arg), do: S
StrategyOn child crashUse when
:one_for_onerestarts only the crashed childchildren are independent
:one_for_allrestarts every childchildren depend on each other
:rest_for_onerestarts the crashed child and all started after itordered start dependencies
DynamicSupervisorruntime start/stop of childrenunbounded, dynamically added workers

Registry & ETS — shared names and data

Registry gives processes lookupable names and pubsub dispatch; ETS is an in-memory key-value table shared by all processes. Both are read-fast, write-safe primitives.

Registry — names and dispatchelixir
{:ok, _} = Registry.start_link(keys: :unique, name: MyApp.Registry)

# name a process through the registry with a via tuple:
{:ok, agent} =
  Agent.start_link(fn -> 0 end,
    name: {:via, Registry, {MyApp.Registry, {:counter, 42}}}
  )

Registry.lookup(MyApp.Registry, {:counter, 42})   #=> [{pid, nil}]

Registry.register(MyApp.Registry, :topic, nil)    # unique: one subscriber
Registry.dispatch(MyApp.Registry, :topic, fn entries ->
  for {pid, meta} <- entries, do: send(pid, {:event, meta})
end)
Since v1.20, registries with keys: :duplicate are backed by an :ordered_set table — faster dispatch and lower memory for large fan-out.
Code sample: {:ok, _} = Registry.start_link(keys: :unique, name: MyApp.Registry) # name a pr
ETS — shared key-value tableelixir
table = :ets.new(:cache, [:set, :public, read_concurrency: true])

:ets.insert(table, {:ada, "pioneer"})
:ets.lookup(table, :ada)           #=> [ada: "pioneer"]
:ets.insert(table, {:ada, "v2"})   # insert overwrites — writes are atomic
:ets.delete(table, :ada)

IO.inspect(:ets.lookup(table, :ada))
IO.inspect(:ets.lookup(table, :ada) |> length())
:ets.insert(table, {:ada, "v2"})
IO.inspect(:ets.lookup(table, :ada))
:ets.delete(table, :ada)
IO.inspect(:ets.lookup(table, :ada))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
ETS lives outside any process (owned by one, survives its readers). Reads are concurrent; coordinate writes through the owner process or accept last-write-wins.
Code sample: table = :ets.new(:cache, [:set, :public, read_concurrency: true]) :ets.insert(t
NeedReach for
name processes, find them, broadcast eventsRegistry — OTP-managed, no races
read-mostly cache or lookup table:ets with :public + read_concurrency
fast counters shared by processes:ets.update_counter/4 — atomic
stateful, consistent business logicGenServer / Agent — not ETS

Gradual typing — zero annotations

since v1.20

Since v1.20 the compiler infers types for all constructs and warns about verified bugs and dead code — no annotations, no setup, no new syntax.

The compiler type checks code you never annotatedelixir
defmodule Math do
  def square(n), do: n * n          # inferred: number() -> number()

  def run(word), do: square(String.length(word))

  def broken do
    square(:oops)   # type violation: atom() can never be number()
  end
end
Code sample: defmodule Math do def square(n), do: n * n # inferred: number() -> nu
Redundant clauses and dead code are warnings nowelixir
def handle(msg) do
  case msg do
    {:ok, value} -> value
    {:error, reason} -> reason
    _other -> :unknown   # warning: this clause can never match
  end
end
Code sample: def handle(msg) do case msg do {:ok, value} -> value {:error, reason}
  • Everything is inferred — variables, functions, structs, guards, even anonymous functions and macros results
  • Only verified findings are reported — warnings fire when the compiler can prove a type violation, never on maybes
  • Dead code detection — unreachable clauses and impossible branches are flagged
  • Narrowing benchmark — Elixir v1.20 passes 12 of 13 categories in the If T: narrowing benchmark suite
  • Zero-config: it runs on mix compile and in the editor through the language server

The dynamic() type

since v1.20

dynamic() is gradual typing done right: unlike static any, a violation is reported whenever the supplied and accepted types are provably disjoint — and usage narrows it.

  • Static `any` — unchecked: passing any where number() is expected never errors
  • `dynamic()` — compatible with everything except when the types are provably disjoint (e.g. an atom() flowing where only binary() can be accepted)
  • Narrowing by usage — the compiler refines the type from how the value is used
Usage narrows the typeelixir
def total(data) do
  # data starts as dynamic(); adding fields a and b
  # narrows data to %{..., a: number(), b: number()}
  data.a + data.b
end
Code sample: def total(data) do # data starts as dynamic(); adding fields a and b # narro
Annotating gradual types in specselixir
@spec size_of(dynamic(integer() or binary())) :: integer()
def size_of(value) when is_integer(value), do: value
def size_of(value) when is_binary(value), do: byte_size(value)

# passing an atom() to size_of/1 is a violation:
# atom() is disjoint from integer() or binary()
Code sample: @spec size_of(dynamic(integer() or binary())) :: integer() def size_of(value) wh

Guards, clauses & occurrence typing

since v1.20

Guards refine types per clause: is_map_key/2 proves a key exists, negations become not_set(), and earlier clauses narrow later ones (occurrence typing).

Guards feed the type systemelixir
def first(list, default)
      when is_list(list) and is_integer(default) do
  # inferred here: list :: list(), default :: integer()
  case list do
    [] -> default
    [head | _] -> head
  end
end
Code sample: def first(list, default) when is_list(list) and is_integer(default) do #
is_map_key proves (and disproves) key presenceelixir
def name(user) when is_map_key(user, :name) do
  user.name          # OK: user narrowed to %{..., name: dynamic()}
end

def without_retries(config) when not is_map_key(config, :retries) do
  config.retries     # violation: :retries is not_set() in this clause
end

def small?(tuple) when tuple_size(tuple) < 3 do
  elem(tuple, 3)     # violation: size is 0..2, index 3 is impossible
end
Code sample: def name(user) when is_map_key(user, :name) do user.name # OK: user n
Occurrence typing across case/cond/with clauseselixir
def upcase(value) do
  case value do
    nil -> "nothing"   # nil is removed from value in later clauses
    other -> String.upcase(other)  # value cannot be nil here
  end
end
Union, intersection and negation of types flow through guards and clauses — including case, cond, with and multi-clause functions.
Code sample: def upcase(value) do case value do nil -> "nothing" # nil is removed fro

Map domain keys

since v1.20

Non-atom map keys get real types: %{123 => "hello"} is %{integer() => binary()} — and Map.put/delete/replace results are tracked key by key.

Domain keys are typed, and can mix with atom keyselixir
sizes = %{123 => "hello"}
# inferred: %{integer() => binary()}

mixed = %{root: 1, 2 => 2}
# inferred: %{integer() => integer(), root: integer()}
Code sample: sizes = %{123 => "hello"} # inferred: %{integer() => binary()} mixed = %{root:
Map.put/delete/change are key-trackedelixir
def enable(map), do: Map.put(map, :debug, true)  # :debug is tracked

def drop(map) do
  map = Map.delete(map, :debug)
  map.debug      # violation: :debug is not_set() after Map.delete/2
end

def swap(map), do: Map.replace(map, :debug, false)
# Map.replace/3 keeps the key only if it was already set — if_set()
Code sample: def enable(map), do: Map.put(map, :debug, true) # :debug is tracked def drop(m
Map.fetch!/2 is checked like direct accesselixir
def name!(map) do
  Map.fetch!(map, :name)   # type-checked like map.name
end

name!(%{})            # warning: :name can never be present in %{}
name!(%{name: "Ada"}) #=> "Ada"
Code sample: def name!(map) do Map.fetch!(map, :name) # type-checked like map.name end n

Typespecs & Dialyzer

@spec/@type document contracts for humans and tools; the built-in checker (v1.20) needs none of them — Dialyzer remains the static-analysis veteran.

Typespecs at a glanceelixir
defmodule Lexer do
  @type token :: {:word, String.t()} | {:int, integer()}
  @typep state :: %{tokens: [token()]}     # private to this module
  @opaque ast :: {term(), [token()]}       # internals are hidden

  @callback lex(String.t()) :: {:ok, ast()} | {:error, term()}

  @spec tokenize(String.t()) :: [token()]
  def tokenize(input) do
    input
    |> String.split(" ")
    |> Enum.map(&{:word, &1})
  end
end
mix dialyzer (via the dialyxir package) runs Erlang success typing over your specs and code.
Code sample: defmodule Lexer do @type token :: {:word, String.t()} | {:int, integer()} @t
AspectDialyzer (dialyxir)Built-in checker (v1.20)
Setupadd dep, build PLT cache firstnone — runs with mix compile
Annotationsoptional @spec-driven success typingnone needed — infers all code
Findingspotential flaws inferred from specsonly verified bugs + dead code
Speedslow on first PLT, cached afterincremental — type-checker caching
Runs whereseparate mix dialyzer taskcompile, mix test, editor inline

Compiler options for types & speed

since v1.20

v1.20 adds module_definition: :interpreted for faster compiles of huge modules, and replaces xref: [exclude:] with no_warn_undefined.

mix.exs — project compiler optionselixir
def project do
  [
    app: :big_app,
    elixirc_options: [
      module_definition: :interpreted,   # faster builds for huge modules
      no_warn_undefined: Legacy.Adapter  # replaces xref: [exclude: ...]
    ]
  ]
end
Code sample: def project do [ app: :big_app, elixirc_options: [ module_defini
  • `:module_definition` — :compiled (default) vs :interpreted; interpreted mode speeds up compilation of very large projects when the type checker becomes the bottleneck
  • `no_warn_undefined` — silences undefined-function warnings for optional dependencies and dynamic calls
  • xref: [exclude: ...] in mix.exs is hard-deprecated since v1.20 — migrate to elixirc_options: [no_warn_undefined: ...]
  • Profiling — per-module type-check times via compiler profile: :time (v1.20.2+) to find slow modules

Mix essentials & task discovery

Project scaffolding, dependency management and app introspection — plus newer discovery helpers like mix help Mod.fun/arity (v1.19) and mix source MODULE (v1.20).

Everyday Mixshell
mix new my_app --sup             # OTP app + supervision tree
mix new my_lib --umbrella        # umbrella project
mix deps                         # list dependencies and their status
mix deps.get                     # fetch dependencies
mix deps.update decimal          # update one dep (mix deps.update --all: all)
mix app.tree                     # print the runtime application tree
mix app.tree --output tree.txt   # since v1.20: write the tree to a file
MIX_OS_DEPS_COMPILE_PARTITION_COUNT=4 mix deps.compile   # v1.19+: parallel deps
Code sample: mix new my_app --sup # OTP app + supervision tree mix new my_lib --u
Finding tasks and docsshell
mix help                        # every available task
mix help mix format             # docs for a specific task
mix help String.trim/1          # module or fun/arity docs (since v1.19)
mix help app:package            # tasks listed per application (since v1.19)
mix source Ecto.Query           # print/open a module source (since v1.20)
Since v1.20, mix help output also renders @type and @callback documentation for the module you ask about.
Code sample: mix help # every available task mix help mix format

ExUnit basics

use ExUnit.Case, test + assert/refute, describe blocks, tags and setup — with --dry-run (v1.20) and run counts on flaky retries (v1.20).

A test moduleelixir
ExUnit.start(seed: 1, max_cases: 1, autorun: false)

defmodule Calc do
  def add(a, b), do: a + b

  defmodule Cache do
    use Agent

    def start_link(_opts), do: Agent.start_link(fn -> %{} end, name: __MODULE__)
  end
end

defmodule CalcTest do
  use ExUnit.Case, async: true

  describe "add/2" do
    @tag :smoke
    test "adds numbers" do
      assert Calc.add(1, 2) == 3
      refute Calc.add(1, 2) == 4
    end

    test "knows its floats", _context do
      assert Calc.add(0.1, 0.2) != 0.3   # classic float surprise
    end
  end

  setup do
    [cache: start_supervised!(Calc.Cache)]  # per-test, auto-stopped
  end
end

ExUnit.run()
Code sample: ExUnit.start(seed: 1, max_cases: 1, autorun: false) defmodule Calc do def add
Running testsshell
mix test                             # everything
mix test test/calc_test.exs          # one file
mix test test/calc_test.exs:12       # the test that contains line 12
mix test --only smoke                # tagged tests only
mix test --exclude smoke             # everything except a tag
mix test --dry-run                   # since v1.20: list tests, run none
mix test --repeat-until-failure 100  # since v1.20: prints remaining runs while retrying
--repeat-until-failure N reruns the suite until it fails (flaky hunting); v1.20 shows how many runs remain as it goes.
Code sample: mix test # everything mix test test/calc_test.exs

Assertions & doctests

assert match?/2 for patterns, assert_in_delta for floats, assert_raise for errors — plus doctests that turn @doc examples into tests.

The assertion toolkitelixir
ExUnit.start(seed: 1, max_cases: 1, autorun: false)

defmodule ToolkitTest do
  use ExUnit.Case, async: true

  test "the assertion toolkit" do
    user = %{name: "Ada"}

    assert match?(%{name: "Ada"}, user)      # does a pattern match?
    assert_in_delta 0.3, 1 / 3, 0.001        # float-safe comparison
    assert_raise ArithmeticError, fn -> div(1, 0) end
    assert_raise RuntimeError, ~r/boom/, fn -> raise "boom" end

    refute Enum.empty?([1])                  # assert not
  end
end

ExUnit.run()
Since v1.18 ExUnit diffs are much smarter — structural, colored comparisons for maps, lists and strings instead of inspect dumps.
Code sample: ExUnit.start(seed: 1, max_cases: 1, autorun: false) defmodule ToolkitTest do
Doctests — examples that must stay trueelixir
defmodule Greeting do
  @doc """
  Builds a greeting.

      iex> Greeting.build("Ada")
      "Hello, Ada!"
  """
  def build(name), do: "Hello, #{name}!"
end

defmodule GreetingTest do
  use ExUnit.Case, async: true
  doctest Greeting      # each iex> example becomes a test
end

ExUnit.start(seed: 1, max_cases: 1, autorun: false)
ExUnit.run()
Code sample: defmodule Greeting do @doc """ Builds a greeting. iex> Greeting.build

Properties of good tests

Opinionated defaults: run everything async, supervise every process you start, wire heavy flows with aliases, and test behaviour — not internals.

  • `async: true` everywhere — tests run on all scheduler cores; keep them free of global state and shared files
  • `start_supervised!/1` — start processes per test with automatic cleanup; never leak start_link results into other tests
  • Aliases in mix.exs — compose flows like "test": ["ecto.create --quiet", "ecto.migrate", "test"]
  • Fixtures/factories — small builder functions over plain maps/structs; avoid one giant mutable fixture file
  • Test behaviour, not implementation — assert on outputs and messages so refactors stay cheap
  • Isolate slow things — tag with @tag :slow and split with mix test --exclude slow in the fast loop

Formatting & docs

mix format is non-negotiable community standard; --migrate modernizes code (v1.15), --no-compile skips recompiles (v1.20), and mix help now renders types/callbacks (v1.20).

Formatting commandsshell
mix format                    # format the whole project
mix format lib/my_app.ex      # one file
mix format --check-formatted  # CI gate: exit 1 if anything is unformatted
mix format --migrate          # since v1.15: also modernizes deprecated code
mix format --no-compile       # since v1.20: format without compiling after
Code sample: mix format # format the whole project mix format lib/my_app.e
.formatter.exselixir
[
  import_deps: [:ecto, :phoenix],
  line_length: 98,
  inputs: ["{mix,.formatter}.exs", "{config,lib,test}/**/*.{ex,exs}"]
]
import_deps pulls in each dependency's formatter config so macros like Ecto queries indent correctly.
Code sample: [ import_deps: [:ecto, :phoenix], line_length: 98, inputs: ["{mix,.formatt
Documentation conventionselixir
defmodule Math do
  @moduledoc """
  Helpers for integer math.
  """

  @doc """
  Doubles a number.

      iex> Math.twice(21)
      42
  """
  def twice(n), do: n * 2

  @spec even?(integer()) :: boolean()
  def even?(n), do: rem(n, 2) == 0
end

Math.twice(21)
|> IO.inspect()
Math.even?(42)
|> IO.inspect()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
@moduledoc and @doc accept heredocs; examples in iex> style double as doctests. Since v1.20, mix help Math prints docs for types and callbacks too.
Code sample: defmodule Math do @moduledoc """ Helpers for integer math. """ @doc """

defprotocol / defimpl

Define a protocol once, implement it per data type. String.Chars (for to_string/1 and interpolation) and Inspect are the ones you will touch most.

A protocol and two implementationselixir
defprotocol Size do
  @doc "Elements, bytes — depends on the data"
  def size(data)
end

defimpl Size, for: Map do
  def size(map), do: map_size(map)
end

defimpl Size, for: BitString do
  def size(bin), do: byte_size(bin)
end

Size.size(%{a: 1, b: 2})   #=> 2
Size.size("hello")         #=> 5

IO.inspect(Size.size(%{a: 1, b: 2}))
IO.inspect(Size.size("hello"))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defprotocol Size do @doc "Elements, bytes — depends on the data" def size(da
Built-ins: derive vs manual implementationelixir
defmodule Account do
  @enforce_keys [:id]
  @derive {Inspect, only: [:id]}   # derive: trim what Inspect shows
  defstruct [:id, plan: :free]
end

# implement manually for interpolation and to_string/1:
defimpl String.Chars, for: Account do
  def to_string(account), do: "Account##{account.id} (#{account.plan})"
end
defmodule Show do
  def run do
    to_string(%Account{id: 7})   #=> Account#7 (free)

    IO.inspect(to_string(%Account{id: 7}))
    IO.inspect(%Account{id: 7, plan: :pro})   # @derive Inspect trimmed the fields
  end
end

Show.run()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Other built-ins: Enumerable (Enum/Stream), Collectable, List.Chars. Structs can also derive Enumerable or Jason.Encoder-style protocols from libraries.
Code sample: defmodule Account do @enforce_keys [:id] @derive {Inspect, only: [:id]} #

Dispatch type checking

since v1.19

Since v1.19 the compiler type checks protocol dispatch: interpolation warns when the type cannot implement String.Chars, for warns for non-Enumerable input.

Caught before Protocol.UndefinedErrorelixir
defmodule Demo do
  def interpolate(range) do
    "#{range}"   # warning: range() does not implement String.Chars
  end

  def iterate(number) do
    for x <- number, do: x * 2   # warning: integer() is not Enumerable
  end
end
These warnings come from protocol dispatch sites being type checked — the same machinery that now flags verified bugs since v1.20. Implementing a protocol for: Any (see fallback) silences them.
Code sample: defmodule Demo do def interpolate(range) do "#{range}" # warning: range(
  • Interpolation #{value} requires String.Chars — ranges, tuples and PIDs will warn (v1.19+)
  • for x <- collection requires Enumerable — passing a plain integer warns (v1.19+)
  • Fix by converting explicitly: Enum.to_list/1, Kernel.to_string/1, or implement the protocol for your type

@fallback_to_any — handle with care

A protocol can fall back to an implementation for: Any, but doing so disables dispatch warnings and can hide bugs — use it only for truly universal defaults.

Fallback declarationelixir
defprotocol Blank do
  @fallback_to_any true
  def blank?(data)
end

defimpl Blank, for: Any do
  def blank?(_data), do: false   # everything else is not blank
end

defimpl Blank, for: List do
  def blank?([]), do: true       # a specific impl wins over Any
  def blank?(_), do: false
end

Blank.blank?([])    #=> true  (List impl)
Blank.blank?(7)     #=> false (Any impl)

IO.inspect([Blank.blank?([]), Blank.blank?(7), Blank.blank?(%{})])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defprotocol Blank do @fallback_to_any true def blank?(data) end defimpl Bla

Behaviours — contracts between modules

@callback defines a module contract, @behaviour opts in, @impl true documents intent — and the compiler checks them all. use wires behaviours in via __using__.

Define and implement a behaviourelixir
defmodule MyApp.Pipeline.Stage do
  @type stage :: term()

  @callback init(opts :: term()) :: stage()
  @callback run(event :: term(), stage()) :: {:ok, term()} | {:error, term()}

  @optional_callbacks init: 1   # not implementing init/1 is fine
end

defmodule MyApp.Pipeline.Log do
  @behaviour MyApp.Pipeline.Stage

  @impl true
  def run(event, state) do
    IO.puts("got #{inspect(event)}")
    {:ok, state}
  end
end

MyApp.Pipeline.Log.run(:click, :state)   #=> got :click
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Missing callbacks, wrong arities and stale implementations become compile warnings. @impl true also guards against defining a function nobody asked for.
Code sample: defmodule MyApp.Pipeline.Stage do @type stage :: term() @callback init(opts
use + __using__ injects behaviour wiringelixir
defmodule MyApp.Pipeline.Stage do
  defmacro __using__(opts) do
    quote do
      @behaviour MyApp.Pipeline.Stage
      @default_opts unquote(opts)

      @impl true
      def init(opts), do: Keyword.merge(@default_opts, opts)
      defoverridable init: 1
    end
  end
end

defmodule Log do
  use MyApp.Pipeline.Stage, level: :debug   # behaviour + init injected
end

Log.init([other: 1])
|> IO.inspect(label: "init merged defaults")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
GenServer, Supervisor and Task are behaviours — use GenServer injects the behaviour declaration, a child_spec/1 and default callbacks you then override.
Code sample: defmodule MyApp.Pipeline.Stage do defmacro __using__(opts) do quote do

AST, quote & unquote

quote turns code into its AST — literals represent themselves, everything else is a {form, metadata, args} tuple. Macro.to_string turns ASTs back into code.

Code as data (REPL)elixir
iex> quote do: 1 + 2
{:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]], [1, 2]}

iex> Code.string_to_quoted!("a + b")
{:+, [line: 1], [{:a, [line: 1], nil}, {:b, [line: 1], nil}]}

iex> Macro.to_string(quote do: Enum.map(list, &(&1 * 2)))
"Enum.map(list, &(&1 * 2))"

IO.inspect(quote(do: 1 + 2))
IO.inspect(Code.string_to_quoted!("a + b"))
IO.inspect(Macro.to_string(quote(do: Enum.map(list, &(&1 * 2)))))
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: iex> quote do: 1 + 2 {:+, [context: Elixir, imports: [{1, Kernel}, {2, Kernel}]]
  • Literals self-represent — atoms, integers, floats, strings and lists are their own AST
  • Everything else is a 3-tuple — {form, metadata, args}
  • Calls look like {fun, meta, [args]}; variables like {:name, meta, nil}
  • Blocks are {:__block__, meta, [statements]} — quote wraps multiple expressions in one
  • `unquote/1` splices an AST into a quoted expression; it is the only way in

defmacro — compile-time code generation

Macros receive ASTs, not values and expand at compile time. That single fact is why a spliced term can run more than once — and why Macro.escape is needed to splice runtime values.

A timing macroelixir
defmodule Timing do
  defmacro timing(label, do: block) do
    quote do
      {elapsed, result} = :timer.tc(fn -> unquote(block) end)
      _ = elapsed   # measured; printing it would make the output non-deterministic
      IO.puts("#{unquote(label)}: #{inspect(result)}")
      result
    end
  end
end

defmodule Runner do
  import Timing

  def run, do: timing("sum", do: Enum.sum(1..1_000))
end

Runner.run()   #=> sum: 500500
# the body above was inlined at compile time
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
The macro also measures the block with :timer.tc; the elapsed time is deliberately not printed, so this example's output is the same on every machine. A macro can only be used from a module defined after it: import Timing at the top level of the file that defines it fails with *module Timing is not loaded but was defined*.
Code sample: defmodule Timing do defmacro timing(label, do: block) do quote do {e
Splicing: what a macro really receiveselixir
defmodule Oops do
  # the argument arrives as AST, and it is spliced TWICE
  defmacro double(x), do: quote(do: unquote(x) + unquote(x))
end

defmodule Demo do
  require Oops

  def bump do
    IO.puts("evaluated")
    1
  end

  def run, do: Oops.double(bump())
end

Demo.run()   #=> 2, and "evaluated" printed twice

# precedence is safe: hygiene wraps spliced AST in parens
defmodule Safe do
  require Oops
  def run, do: Oops.double(2 + 3)
end

Safe.run()   #=> 10, not 8
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Runtime values (maps, lists) must become AST before splicing: Macro.escape(value). Atoms, numbers and binaries embed directly.
Code sample: defmodule Oops do # the argument arrives as AST, and it is spliced TWICE def

use & __using__

use Module calls its __using__/1 macro and injects the quoted code into your module — how use GenServer wires behaviours, defaults and child specs in one line.

A tiny __using__ of your ownelixir
defmodule MiniLogger do
  defmacro __using__(opts) do
    level = Keyword.get(opts, :level, :info)   # runs at compile time

    quote do
      @level unquote(level)

      def log(message), do: IO.puts("[#{@level}] " <> message)
    end
  end
end

defmodule MyApp do
  use MiniLogger, level: :debug
  def run, do: log("started")    #=> [debug] started
end

MyApp.run()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule MiniLogger do defmacro __using__(opts) do level = Keyword.get(op
  • `use` = module-level: inject attributes, functions, behaviour declarations (GenServer-style)
  • `import` = function-level: bring existing functions into scope — no code generation
  • behaviours = contract-level: the module implements callbacks itself, nothing is injected
  • Reach for use last: it hides code, slows the compiler, and makes mix format/editors guess more

Hygiene & rules of thumb

Macro bodies are hygienic — they cannot touch caller variables unless you say var!. And the golden rule stands: if a function can do it, write a function.

A hygienic macroelixir
defmodule Debug do
  defmacro log_result(expr) do
    quote do
      result = unquote(expr)
      IO.puts("got: #{inspect(result)}")   # result is module-unique
      result
    end
  end
end

defmodule Demo do
  import Debug
  def go, do: log_result(2 + 3)   #=> got: 5 (and returns 5)
end

Demo.go()
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: defmodule Debug do defmacro log_result(expr) do quote do result = un
  • `var!(name)` — deliberately read or assign a variable in the caller scope, escaping hygiene
  • `import` inside `quote` — control exactly what the caller scope gains from your macro
  • `Macro.escape/1` — turn runtime values into ASTs before unquote
  • `Macro.underscore/1`, `Macro.camelize/1` — the handy name utils live in the Macro module

Macro (unneeded)

defmodule Bad do
  defmacro greet(name) do
    quote do: "Hello, " <> unquote(name)
  end
end

Bad.greet("Ada")     # works...
# &Bad.greet/1       # compile error: cannot capture a macro
Code sample: defmodule Bad do defmacro greet(name) do quote do: "Hello, " <> unquote(na

Function (just right)

defmodule Good do
  def greet(name), do: "Hello, " <> name
end

"Ada" |> Good.greet()    # pipes
caps = &Good.greet/1     # captures
Good.greet("Ada")        # plain calls
Code sample: defmodule Good do def greet(name), do: "Hello, " <> name end "Ada" |> Good.gr

Macros cannot be captured or passed as values, and every use re-expands at compile time. Functions compose with pipes, captures and higher-order calls.

/ always returns a float

The / operator always returns a float — even for exact division. Use div/2 and rem/2 for integer math.

Trapped

10 / 4    #=> 2.5
10 / 5    #=> 2.0   exact division is STILL a float
is_float(10 / 5)   #=> true
10 / 0    #=> ArithmeticError (never :infinity)
Code sample: 10 / 4 #=> 2.5 10 / 5 #=> 2.0 exact division is STILL a float is_float(1

Integer math

div(10, 4)   #=> 2     truncates toward zero
rem(10, 4)   #=> 2     sign follows the dividend
div(10, 0)   #=> ArithmeticError
Code sample: div(10, 4) #=> 2 truncates toward zero rem(10, 4) #=> 2 sign follows

Float.floor/1 or trunc/1 can convert a float result, but div/2 is the idiomatic integer division.

is_atom(nil) is true

Booleans and nil are atoms — true, false, nil are just the atoms :true, :false, :nil with special literals.

elixirelixir
is_atom(nil)     #=> true   nil is the atom :nil
is_atom(true)    #=> true   booleans are atoms
is_boolean(nil)  #=> false  but not a boolean

# only plain atoms like :ok:
# the plain-atom test:
is_atom(:ok) and not is_boolean(:ok) and not is_nil(:ok)

IO.inspect([
  is_atom(nil),
  is_atom(true),
  is_boolean(nil),
  is_atom(:ok) and not is_boolean(:ok) and not is_nil(:ok),
])
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: is_atom(nil) #=> true nil is the atom :nil is_atom(true) #=> true boo

Charlist vs string — the single-quote trap

Single quotes create charlists (lists of codepoints), not strings — 'hello' == "hello" is false, and lists of integers print as charlists.

Charlist

'hi' == "hi"        #=> false
is_list('hi')       #=> true
is_binary('hi')     #=> false
IO.inspect('hi')    #=> 'hi'  looks like a string, is not
[104, 105]          #=> 'hi'  lists of integers print as charlists
Code sample: 'hi' == "hi" #=> false is_list('hi') #=> true is_binary('hi') #

String (binary)

"hi" == "hi"       #=> true
is_binary("hi")    #=> true  UTF-8 encoded binary
String.to_charlist("hi")   #=> 'hi'
to_string('hi')    #=> "hi"  convert between the two
Code sample: "hi" == "hi" #=> true is_binary("hi") #=> true UTF-8 encoded binary St

Erlang APIs (like :crypto or :filename) expect charlists — that is why they exist. In Elixir code, prefer strings.

Map pattern matching is partial

%{name: _} matches any map containing a :name key — extra keys are ignored. Use structs or a map_size/1 guard when you need strictness.

Surprising

def name(%{name: name}), do: name

name(%{name: "Ada", junk: 1, extra: 2})
#=> "Ada"  — matched anyway, extra keys ignored

name(%{other: 1})
#=> FunctionClauseError — key truly missing
Code sample: def name(%{name: name}), do: name name(%{name: "Ada", junk: 1, extra: 2}) #=> "

Strict

def name(%User{name: name}), do: name
# structs match exact keys only

def only_name(map) when map_size(map) == 1,
  do: map.name
# guard rejects maps with more keys
Code sample: def name(%User{name: name}), do: name # structs match exact keys only def only_

Partial matching is a feature for updating and defaulting — but a trap when you intended to assert the exact shape.

with without else swallows mismatches

When no clause matches, a with without else returns the raw non-matching value silently — always provide an else for errors you care about.

Silent fallthrough

with {:ok, user} <- fetch(id) do
  user.name
end
# fetch/1 returned {:error, :not_found}?
# the with returns {:error, :not_found} —
# no raise, no warning, do block skipped
Code sample: with {:ok, user} <- fetch(id) do user.name end # fetch/1 returned {:error, :no

Explicit else

with {:ok, user} <- fetch(id) do
  user.name
else
  {:error, reason} ->
    Logger.warning("fetch failed: #{inspect(reason)}")
    {:error, reason}
end
Code sample: with {:ok, user} <- fetch(id) do user.name else {:error, reason} -> Logg

The returned non-matching value flows onward through your pipeline as if it were valid data — the bug surfaces far from the cause.

Regex cannot be a struct field default

since v1.19

Since v1.19 a Regex struct can no longer be a struct field default — the compiler rejects defstruct pattern: ~r/foo/; build it in new/1.

Fails to compile (v1.19+)

defmodule Route do
  defstruct pattern: ~r/foo/i
  # error: invalid default for struct field :pattern
  # regexes are not allowed as struct defaults (v1.19+)
end
Code sample: defmodule Route do defstruct pattern: ~r/foo/i # error: invalid default for

Build in new/1

defmodule Route do
  defstruct pattern: nil

  def new(pattern \\ ~r/foo/i) do
    %__MODULE__{pattern: pattern}
  end
end
Code sample: defmodule Route do defstruct pattern: nil def new(pattern \\ ~r/foo/i) do

The same applies to any value that cannot live as a plain literal in compiled struct form.

size(n) in patterns needs a pin

since v1.20

In bitstring patterns, size(n) and unit(n) now require the variable to be pinned — <<x::size(^n)>> (hard-deprecated without it since v1.20).

Old form (hard-deprecated in v1.20)

def header(<<len, body::size(len)>>) do
  # v1.20 warning: size must use the pin operator:
  # expected <<len, body::size(^len)>>
end
Code sample: def header(<<len, body::size(len)>>) do # v1.20 warning: size must use the pin

Pinned (v1.20+)

def header(<<len, body::size(^len)>>) do
  {:ok, body}   # len was bound by the earlier segment
end
Code sample: def header(<<len, body::size(^len)>>) do {:ok, body} # len was bound by the

The pin makes the hidden dependency (later segment sized by an earlier one) explicit — and lets the compiler check it.

File.stream!/3 argument order swapped

since v1.20

Hard-deprecated in v1.20: the old (path, modes, lines_or_bytes) order became (path, lines_or_bytes, modes).

Old order (v1.19 and earlier)

File.stream!("app.log", [:read, :utf8], 2048)
# path, modes, lines_or_bytes   <- old order

# v1.20 emits a deprecation warning (or errors)
Code sample: File.stream!("app.log", [:read, :utf8], 2048) # path, modes, lines_or_bytes <-

New order (v1.20+)

File.stream!("app.log", 2048, [:read, :utf8])
# path, lines_or_bytes, modes   <- new order

File.stream!("app.log")        # defaults still fine
Code sample: File.stream!("app.log", 2048, [:read, :utf8]) # path, lines_or_bytes, modes <-

File.stream!/1 and File.stream!/2 with a single option list keep working — only the 3-argument form changed order.

Atoms are never garbage-collected

Atoms live forever on the VM — String.to_atom/1 on unbounded user input is a memory-exhaustion DoS. Use String.to_existing_atom/1 at trust boundaries.

Unbounded atoms

def metric(name) do
  # every unique request mints a PERMANENT atom:
  String.to_atom("metric_" <> name)
end
# attacker sends millions of unique names -> OOM
Code sample: def metric(name) do # every unique request mints a PERMANENT atom: String.to

Existing atoms only

def metric(name) do
  String.to_existing_atom("metric_" <> name)
end
# raises ArgumentError for unknown atoms —
# rescue it, or pre-register the valid set
Code sample: def metric(name) do String.to_existing_atom("metric_" <> name) end # raises Ar

The BEAM caps total atoms (~1M by default) — but the memory is allocated long before the limit hits.

Enum chains blow up memory — Stream does not

Every chained Enum step builds a full intermediate list; Stream composes lazily — but a stream does nothing until an Enum function pulls it.

elixirelixir
# eager: 10M-element list, then another 5M-element list
1..10_000_000
|> Enum.map(&(&1 * 3))
|> Enum.filter(&rem(&1, 2) == 0)
|> Enum.sum()

# lazy: one pass, constant memory
1..10_000_000
|> Stream.map(&(&1 * 3))
|> Stream.filter(&rem(&1, 2) == 0)
|> Enum.sum()      # <- the Enum call runs the stream

1..1_000_000
|> Enum.map(&(&1 * 3))
|> Enum.filter(&rem(&1, 2) == 0)
|> Enum.sum()
|> IO.inspect(label: "eager")

1..1_000_000
|> Stream.map(&(&1 * 3))
|> Stream.filter(&rem(&1, 2) == 0)
|> Enum.sum()
|> IO.inspect(label: "lazy")
verified · Elixir 1.20.2 (compiled with Erlang/OTP 29) · Erlang/OTP 29
Code sample: # eager: 10M-element list, then another 5M-element list 1..10_000_000 |> Enum.ma

Operators & precedence

Highest precedence first. and/or require a boolean left argument; &&/|| accept any value where only nil and false are falsy.

PrecedenceOperatorsAssociativityNotes
1.leftcall: mod.fun(arg)
2@—module attribute: @doc
3+ - ! ^ notrightunary, incl. pin ^x
4* /left/ always yields float
5+ -leftbinary add/subtract
6++ -- <>rightlist diff, concatenation
7in not inleftmembership: x in list
8|>leftpipe into first arg
9< > <= >=leftterm comparison
10== !=leftloose equality: 1 == 1.0
11=== !==leftstrict equality: 1 !== 1.0
12&& ||rightshort-circuit, truthy
13and orrightshort-circuit, boolean left
14=rightmatch operator
15&—capture: &(&1 + 1)
16|—type union in specs
  • `==` vs `===` — loose treats 1 == 1.0 as true; strict does not
  • `and`/`or` vs `&&`/`||` — the former demand a boolean left argument, the latter accept anything
  • Use parentheses when mixing comparison with boolean operators — readability beats memorized precedence

Literals quick reference

Numbers with bases and underscores, atoms, strings vs charlists, bitstrings, collections — the syntax surface in one table.

LiteralSyntaxNotes
Integer1_000_000 0b1010 0o777 0xFFunderscores + bases 2/8/16
Float3.14 1.0e-364-bit; / returns one
Atom:ok :+ :weird_name?never garbage-collected
String~s(hello) — double-quoted textUTF-8 binary; #{} interpolation, <> concat
Charlist'hello'list of codepoints: [104, 101, ...]
Bitstring / binary<<1, 2, 3>> <<x::size(8)-unit(1)>>pattern-matchable segments
Tuple{:ok, value}fixed size, fast elem/2
List[1, 2, 3] [head | tail]singly linked, prepends are O(1)
Keyword list[name: :ada, age: 1]atom keys, ordered, duplicates ok
Map%{a: 1} %{1 => :one}mixed keys: %{1 => :x, a: :y}
Struct%User{name: :ada}defstruct, exact keys, update %{u | name: x}
Range1..10 1..10//2step struct, Enumerable
Functionfn x -> x end &Mod.fun/1anonymous + capture

Sigils

Sigil letter picks the builder, delimiter picks the quoting, lowercase enables interpolation and escapes. Extras like ~w(a b c)a take a trailing modifier.

SigilMeaningExample
~sstring, interpolated~s(hello #{name})
~Sstring, raw — no escapes~S(C:\path \n literal)
~ccharlist~c(hello)
~wword list, modifiers a c s~w(a b c)a → [:a, :b, :c]
~rregex, modifiers i u x ...~r/\d+/i
~DDate struct~D[2026-06-03]
~TTime struct~T[09:00:00.500]
~NNaiveDateTime — no zone~N[2026-06-03 09:00:00]
~UDateTime in UTC~U[2026-06-03 09:00:00Z]
Heredocmulti-line stringthree double-quote delimiters on their own lines
  • Delimiters — ~s(...), ~s[...], ~s{...}, ~s<...>, ~s|...|, ~s/.../ all work
  • Lowercase = processed (interpolation + escapes); uppercase = raw
  • Custom sigils: define a sigil_x/2 macro (lowercase letter) in a module and import it

Common guards

Built-in type checks usable in when clauses — all safe, side-effect-free, and (since v1.20) feeding the type checker.

GuardTrue when
is_integer/1an integer (0, -3, 0xFF)
is_float/1a float (2.5)
is_number/1integer or float
is_binary/1a binary — strings included
is_bitstring/1a bitstring (binaries are bitstrings)
is_atom/1an atom — incl. nil, true, false
is_boolean/1exactly true or false
is_nil/1exactly nil
is_list/1a list (charlists too)
is_map/1a map (structs included)
is_tuple/1a tuple
is_function/1an anonymous or named function
is_function/2a function of that arity: is_function(f, 2)
is_struct/1a struct (a map with __struct__)
is_struct/2a struct of that module: is_struct(x, User)
is_map_key/2the map has that key — narrows types in v1.20
is_exception/1,2an exception struct (optionally of a module)
in/2x in list or x in range
  • Guards can be combined with and, or, not — and custom guards via defguard
  • Only a fixed set of functions is allowed in guards — no String.length/1, no user functions

Essential stdlib

The modules you will open in h every day — one go-to line each. JSON is built in since v1.18; Enum + String + Map cover most day-to-day work.

ModuleGo-to functions
Kernel+ <> |> hd/1 tl/1 dbg/1 all is_* guards
Enummap/2 filter/2 reduce/3 find/2 sort/2 group_by/2 sum/1
Stringsplit/2 join/2 upcase/1 trim/1 contains?/2 replace/3
Mapget/3 put/3 fetch!/2 merge/2 new/2 keys/1
Listfirst/1 last/1 flatten/1 wrap/1 duplicate/2 zip/2
Tupleelem/2 put_elem/3 tuple_size/1
Integerparse/1 to_string/1 digits/2 ceil_div/2 popcount/1
Floatparse/1 to_string/1 round/2
IOputs/2 inspect/2 gets/2 iodata_empty?/1
Fileread/1 write/3 read!/1 stream!/3 mkdir_p/1
Pathjoin/2 expand/1 relative/2 extname/1
Processself/0 alive?/1 monitor/1 flag/2 get_label/1
Taskasync/1 await/2 async_stream/3
GenServerstart_link/3 call/3 cast/2 reply/2
Registrystart_link/1 register/3 lookup/2 dispatch/4
JSONencode!/1 encode/1 decode/2 decode!/2 — built in since v1.18

New in v1.20 — quick table

since v1.20

The v1.20 additions worth reaching for: sharp List functions, a faster Registry backend, mix source, mix test --dry-run and the gradual type system itself.

AdditionWhereSince
Integer.ceil_div/2Integerv1.20
Integer.popcount/1Integerv1.20
List.first!/1 and List.last!/1Listv1.20
IO.iodata_empty?/1IOv1.20
Process.get_label/1Processv1.20
Regex.import/1Regexv1.20
Enum.min_max/2 with sorterEnumv1.20
File.read/2 accepting [:raw]Filev1.20
mix source MODULE — print/open sourceMixv1.20
mix test --dry-runMixv1.20
mix help renders type/callback docsMixv1.20
elixirc_options: [module_definition: ...]compilerv1.20
gradual type checking of all codecompilerv1.20
Registry duplicate keys on :ordered_setRegistryv1.20
  • Also breaking in v1.20 — require no longer expands the required module at compile time; raw CR characters in strings or comments are rejected
  • Hard-deprecated in v1.20 — old File.stream!/3 order, unpinned size(var) in bit patterns, Logger backends, xref: [exclude:]