
Elixir from the Basics: A Hands-On Tutorial
From iex to GenServers, with Java comparisons, exercises and answers · for Unni
Welcome, Unni
This guide is written for one specific reader: someone who has spent years designing Java and Spring Boot systems, knows APIs and system design well, and wants to pick up Elixir just for the fun of it. Since it's a hobby, there's no deadline and no exam. The lessons are short, the snippets are small, and each one tries to explain why Elixir works the way it does as well as how.
You can start this week or next week. A relaxed pace is about 30–60 minutes a session, three or four sessions a week. That gets you through the eight lessons and a small capstone project in roughly two to three weeks. There's a suggested schedule near the end.
How to use this guide: keep an iex window open the whole time and type the snippets in yourself instead of pasting them. In Elixir, the feedback you get from the shell is a big part of how you learn.
Why Elixir?
Elixir is a functional, dynamically typed language created by José Valim in 2012. It runs on the BEAM, the virtual machine built for Erlang. Ericsson designed Erlang in the late 1980s to run telephone switches, which had to handle huge numbers of simultaneous calls and could essentially never go down. Elixir takes that battle-tested runtime and adds a friendly, Ruby-inspired syntax, great tooling (mix, iex, ExUnit, Hex), and macros.
Four ideas make Elixir feel different from Java:
- The BEAM VM. Like the JVM, the BEAM runs bytecode. Unlike the JVM, it was designed first around concurrency and fault tolerance rather than raw single-thread speed. It schedules millions of tiny processes across all your CPU cores and preempts them fairly, so one busy process can't starve the others.
- Immutability. Once you create a value, it never changes. You transform data into new data. That removes a whole category of bugs (shared mutable state, defensive copies, "who changed this object?") and makes concurrency much safer.
- Lightweight processes. A BEAM process is not an OS thread. It's a tiny isolated unit with its own memory, starting at about 2.6 KB, that talks to other processes only by sending messages. Running hundreds of thousands of them on a laptop is normal.
- "Let it crash." Instead of wrapping everything in defensive
try/catch, you write the happy path and let unexpected failures crash the process. A supervisor notices and restarts it in a known-good state. Errors stay isolated because processes share nothing.
Coming from Java: Think of the BEAM as a JVM where every object that needs its own state lives in its own tiny "virtual thread" with a private heap, and the only way to reach it is to send it a message. There are no locks or
synchronizedblocks, and noConcurrentModificationException. Java 21's virtual threads are the closest thing in the Java world, but they still share one heap. BEAM processes don't.
Setup
You need two things: Erlang/OTP (the runtime) and Elixir (the language). Any Elixir 1.15 or newer works for this guide. At the time of writing, elixir-lang.org lists Elixir 1.20 with OTP 28.
Option A: the official installers (easiest)
- macOS:
brew install elixir(Homebrew installs Erlang too). - macOS / Ubuntu / Windows: the official install script from elixir-lang.org/install:
curl -fsSO https://elixir-lang.org/install.sh
sh install.sh elixir@latest otp@latest
The script prints the PATH lines to add to your shell profile. Run sh install.sh --help to see the options. On Windows there's an install.bat equivalent, plus a regular installer.
Option B: asdf (best if you like version managers)
asdf works like SDKMAN! for Java: one tool that manages versions of many languages. Since asdf 0.16, asdf set replaces the older asdf global and asdf local commands.
asdf plugin add erlang
asdf plugin add elixir
asdf install erlang latest
asdf install elixir latest
asdf set -u erlang latest # -u = your user-wide default (~/.tool-versions)
asdf set -u elixir latest
Building Erlang through asdf compiles it from source, so check the asdf-erlang README for the build dependencies on your OS first. If that sounds like a chore, use Option A. (mise is a popular asdf-compatible alternative.)
Check it works
elixir --version
iex: the interactive shell
iex is your playground, like jshell but used far more heavily by Elixir developers.
$ iex
iex> 1 + 1
2
iex> h String.upcase # built-in docs for any function
iex> i "hello" # info about any value: type, protocols, etc.
To quit, press Ctrl+C twice. Use the up arrow to recall history.
Running a script (.exs)
Elixir has two file extensions: .ex files get compiled (application code), and .exs files are scripts that get interpreted (experiments, tests, config).
# hello.exs
name = "Unni"
IO.puts("Hello, #{name}! Welcome to Elixir.")
elixir hello.exs
You can also load a file into a running shell with iex hello.exs, or with c "hello.exs" from inside iex.
mix: the build tool
mix combines what Maven/Gradle and Spring Initializr do for you: it creates projects, compiles them, runs tests, and fetches dependencies from Hex (Elixir's Maven Central).
mix new hello_app # creates a new project
cd hello_app
mix test # runs the generated test
iex -S mix # starts iex with your project loaded
Inside iex -S mix, type recompile after you edit a file.
Coming from Java:
mix.exsplays the role ofpom.xmlorbuild.gradle. It's plain Elixir code, though, not XML. Dependencies go in thedepsfunction, andmix deps.getdownloads them.
Lesson 1: Core Syntax and Basic Types
Goal: get comfortable in iex and know Elixir's basic building blocks: integers, floats, atoms, strings (binaries), booleans, and nil.
Everything in Elixir is an expression that returns a value, including if and case. There are no statements and no void. Comments start with #.
Numbers
1 + 2 # 3
10 / 2 # 5.0 (/ always returns a float)
div(10, 3) # 3 (integer division)
rem(10, 3) # 1 (remainder)
2 ** 100 # 1267650600228229401496703205376
1_000_000 # underscores for readability
0.1 + 0.2 # 0.30000000000000004 (usual float caveats)
Integers have arbitrary precision, so they never overflow.
Coming from Java: No
intvslongvsBigInteger. One integer type grows as needed./never truncates, so usediv/2when you want integer division.
You'll often see functions written as div/2. The /2 is the arity, meaning the number of arguments. In Elixir, div/2 and a hypothetical div/3 would be two completely different functions.
Atoms
An atom is a constant whose name is its value, like a lightweight enum value or an interned symbol.
:ok
:error
:hello_world
is_atom(:ok) # true
true == :true # true (booleans are just atoms)
is_atom(nil) # true (so is nil)
Atoms are everywhere: {:ok, value} and {:error, reason} are the standard way to return results. Module names are atoms too.
Strings and binaries
Strings use double quotes, are UTF-8, and are actually binaries (sequences of bytes).
name = "Unni"
"Hello, #{name}!" # interpolation: "Hello, Unni!"
"Hello, " <> name # concatenation with <>
String.length("héllo") # 5 characters (graphemes)
byte_size("héllo") # 6 bytes (é takes 2 in UTF-8)
String.upcase("elixir") # "ELIXIR"
is_binary("hi") # true
<<104, 105>> == "hi" # true: a string is just bytes
Single quotes are different. They create a charlist (a list of integer code points), which mostly shows up when you call Erlang libraries. Since Elixir 1.15, iex shows charlists with the ~c sigil:
~c"hi" # a charlist; iex displays ~c"hi"
~c"hi" == [104, 105] # true
The rule of thumb: always use double quotes.
Coming from Java:
<>is+for strings, and"#{expr}"works like a cleanerString.format. A JavaStringis a sequence of UTF-16 chars; an Elixir string is UTF-8 bytes, and theStringmodule handles the Unicode details for you.
Booleans and nil
true and false # false (and/or/not require real booleans)
not true # false
nil || "default" # "default" (||, &&, ! accept any value)
!nil # true
Only false and nil are "falsy". Everything else is truthy, including 0, "", and [].
Coming from Java:
nilis likenull, but calling a function on it doesn't throw a surprise NPE deep in a call chain. Most functions just don't match onnil, and the error message tells you exactly what value showed up. You'll also usenilmuch less, because{:ok, value}/{:error, reason}tuples fill the role ofOptionaland checked exceptions.
iex survival kit
| Command | What it does |
|---|---|
h Enum.map |
Show docs for a module or function |
i value |
Inspect a value's type and details |
v() |
The result of the last expression |
c "file.ex" |
Compile and load a file |
recompile |
Recompile the current mix project |
IO.inspect(x) |
Print a value and return it (great for debugging) |
Practice
- In
iex, work out7 / 2,div(7, 2), andrem(7, 2). Before you hit Enter, predict whether each one returns an integer or a float. - Make
first = "Ada"andlast = "Lovelace", then build"Lovelace, Ada"two ways: with interpolation and with<>. - Try
String.lengthandbyte_sizeon"naïve"and on an emoji such as"👍". Why are the numbers different? - Use
h Stringto browse the docs and find a function that reverses a string. Try it.
Self-check
- What's the difference between
/anddiv/2? - What does the
/1inString.upcase/1mean? - Which two values are falsy?
- Why is
"hi"a binary, while~c"hi"is a list?
Lesson 2: Collections
Goal: know when to use a list, tuple, keyword list, map, or struct, and get started with the Enum module.
Lists
Lists are linked lists, not arrays. Adding to the front is fast. Indexing and appending have to walk the list, so they cost O(n).
list = [1, 2, 3]
[0 | list] # [0, 1, 2, 3] prepend: fast
list ++ [4] # [1, 2, 3, 4] append: copies the list
hd(list) # 1 (head)
tl(list) # [2, 3] (tail)
length(list) # 3
Tuples
Tuples are fixed-size and stored together in memory. You can read any element quickly, but "changing" one copies the whole tuple. Use them for small, fixed groupings.
point = {3, 4}
elem(point, 0) # 3
put_elem(point, 1, 10) # {3, 10} (a new tuple)
File.read("missing.txt") # {:error, :enoent}
The tagged tuple, like {:ok, result} or {:error, reason}, is Elixir's most common idiom.
Keyword lists
A keyword list is a list of {atom, value} pairs with a nicer syntax. It's mostly used for options passed to functions.
opts = [name: "Unni", level: :beginner]
opts == [{:name, "Unni"}, {:level, :beginner}] # true
opts[:name] # "Unni"
String.split("a,b,,c", ",", trim: true) # ["a", "b", "c"]
When a keyword list is the last argument, you can drop the brackets, which is why trim: true above looks like a named parameter.
Maps
Maps are Elixir's go-to key–value structure, like a HashMap, except immutable.
user = %{name: "Unni", role: "architect"}
user.name # "Unni" (atom keys only)
user[:role] # "architect"
%{user | role: "hobbyist"} # update an existing key
Map.put(user, :lang, "Elixir") # add a new key
scores = %{"alice" => 10, "bob" => 7} # any type can be a key
scores["alice"] # 10
Map.get(scores, "carol", 0) # 0 (default)
user.missing raises a KeyError, while user[:missing] returns nil. Pick whichever behavior you want. Don't rely on maps keeping keys in any particular order.
Structs
A struct is a map with a fixed set of fields, defined inside a module.
defmodule Book do
defstruct title: nil, author: nil, pages: 0
end
book = %Book{title: "Elixir in Action", author: "Saša Jurić"}
book.pages # 0 (the default)
%{book | pages: 400} # a new struct with pages updated
One quirk: in iex, enter the defmodule first and then use %Book{} on a later line. That works fine. But if you put both at the top level of a single .exs script, the compiler complains that the struct isn't defined yet. Put the struct in its own module file, or use it inside a function.
Mistyping a field, as in %Book{titel: "x"}, is an error, so structs catch typos that plain maps miss. Add @enforce_keys [:title] before defstruct to make a field required.
Coming from Java: A struct is close to a Java
record: named fields, value semantics, no setters. The difference is that it holds no behavior. The functions live in the module next to it.
Enum basics
Enum works on anything enumerable: lists, maps, ranges, and more.
nums = [1, 2, 3, 4, 5]
Enum.map(nums, fn n -> n * 2 end) # [2, 4, 6, 8, 10]
Enum.filter(nums, fn n -> rem(n, 2) == 0 end) # [2, 4]
Enum.reduce(nums, 0, fn n, acc -> n + acc end) # 15
Enum.sum(nums) # 15
Enum.sort([3, 1, 2], :desc) # [3, 2, 1]
Enum.count(1..10) # 10 (1..10 is a range)
On a map, Enum gives you {key, value} tuples:
prices = %{apple: 1.0, mango: 2.5}
Enum.map(prices, fn {fruit, price} -> {fruit, price * 2} end) |> Map.new()
# %{apple: 2.0, mango: 5.0}
Coming from Java:
Enumis the Stream API without the.stream()and.collect(...)ceremony.Enum.mapreturns a list right away (it's eager). Elixir's lazy equivalent isStream, covered in Lesson 6.
Practice
- Make a list of your three favorite languages. Prepend
"Elixir"and get its length. - Build a map of three cities and their populations. Use
Enum.filterto keep the ones over a million, then useMap.newto turn the result back into a map. - Define a
Moviestruct withtitle,year, andwatched(defaultfalse). Make one, then make a copy withwatched: true. - Use
Enum.reduceto find the longest string in["ant", "giraffe", "cat"].
Self-check
- Why is
[x | list]fast butlist ++ [x]slow? - When would you pick a tuple over a list?
- What's the difference between
map.keyandmap[:key]when the key is missing? - How is a struct different from a plain map?
Lesson 3: Pattern Matching
Goal: understand that = is a match, not an assignment, and use pattern matching with case, cond, if, and with to write clear branching code.
This lesson changes how you'll read Elixir code more than any other.
= is the match operator
= says "make the left side match the right side." If the left side contains variables, they get bound to whatever makes the match succeed.
x = 1 # binds x to 1
1 = x # works: 1 matches 1
2 = x # ** (MatchError) no match of right hand side value: 1
Destructuring
{status, value} = {:ok, 42} # status = :ok, value = 42
[first, second | rest] = [1, 2, 3, 4] # first = 1, second = 2, rest = [3, 4]
%{name: name} = %{name: "Unni", role: "architect"} # name = "Unni"
{_, y} = {10, 20} # _ means "don't care"
{:ok, contents} = {:ok, "data"} # only succeeds for {:ok, _}
A map pattern only needs the keys you mention to be present. A tuple or list pattern has to match the shape exactly.
The pin operator ^
Normally a variable on the left gets rebound. Put ^ in front of it to say "match against the value this variable already has."
expected = :ok
{^expected, result} = {:ok, "done"} # matches; result = "done"
{^expected, result} = {:error, "x"} # MatchError: :error isn't :ok
case
case tries each pattern from top to bottom and runs the first one that matches. You can add guards with when.
case File.read("notes.txt") do
{:ok, contents} -> "Read #{byte_size(contents)} bytes"
{:error, :enoent} -> "No such file"
{:error, reason} -> "Failed: #{inspect(reason)}"
end
case 150 do
n when n > 100 -> :big
n when n > 10 -> :medium
_ -> :small
end
# :big
cond and if
Use cond when you're checking conditions rather than shapes, like an if / else if chain. The final true -> is the catch-all.
temp = 38
cond do
temp > 40 -> "hot"
temp > 25 -> "warm"
true -> "cool"
end
# "warm"
if is for a single yes/no check, and it returns a value:
label = if String.length("elixir") > 3, do: "long", else: "short"
with: chaining steps that can fail
with runs a series of matches. If every step matches, the do block runs. If any step fails, its value goes to else, or is returned as-is when there's no else.
defmodule Signup do
def run(params) do
with {:ok, name} <- Map.fetch(params, "name"),
{:ok, age} <- Map.fetch(params, "age"),
true <- age >= 18 do
{:ok, "Welcome, #{name}"}
else
:error -> {:error, :missing_field}
false -> {:error, :too_young}
end
end
end
Signup.run(%{"name" => "Unni", "age" => 40}) # {:ok, "Welcome, Unni"}
Signup.run(%{"name" => "Unni"}) # {:error, :missing_field}
Coming from Java:
caseis like Java 21's pattern-matchingswitch, except it can take apart any data shape, not just records.withreplaces a stack of nestedif (x != null)checks or a chain ofOptional.flatMapcalls: the happy path reads top to bottom, and the failures are handled in one place.
Practice
- Destructure
{:user, "Unni", 40}into three variables. Then write a pattern that only matches when the first element is:user. - Write a
casethat takes a{:ok, value}or{:error, reason}tuple and returns a friendly string. - Use
condto write FizzBuzz for one number ("Fizz","Buzz","FizzBuzz", or the number itself). - Extend
Signup.run/1so it also requires an"email"that contains"@".
Self-check
- Why does
1 = xwork whenxis 1? - What does
^do, and when do you need it? - When would you use
condinstead ofcase? - What happens in a
withwhen a step doesn't match and there's noelse?
Lesson 4: Immutability
Goal: understand the difference between rebinding a name and mutating data, why immutability makes concurrency safer, and how to "change" data by transforming it.
Data never changes
Every function returns new data. The original stays exactly as it was.
list = [1, 2, 3]
List.delete(list, 2) # [1, 3]
list # still [1, 2, 3]
user = %{name: "Unni"}
Map.put(user, :lang, "Elixir") # %{name: "Unni", lang: "Elixir"}
user # still %{name: "Unni"}
Rebinding is not mutation
You can write x = x + 1. That doesn't change any value in memory. It points the name x at a new value. Anything that captured the old value still sees the old value:
x = 1
add_x = fn n -> n + x end # the function captures x = 1
x = 100 # rebinds the name
add_x.(1) # 2, not 101
Variables are also scoped to their block. Rebinding inside an if doesn't leak out (the compiler warns you about it):
count = 0
if true do
count = count + 1 # warning: variable "count" is unused
end
count # 0
The fix is to use the value the if returns:
count = 0
count = if true, do: count + 1, else: count
count # 1
Why it helps concurrency
If data can't change, two processes can never "see it half-updated," and you never need locks to read it safely. On the BEAM, each process has its own heap, and messages are copied between them, so sharing mutable state isn't even possible. That's what makes "let it crash" work: a crashed process can't leave corrupted shared state behind for anyone else.
It's also cheap. Elixir uses structural sharing, so new versions reuse the unchanged parts of the old one:
list = [2, 3, 4]
new_list = [1 | list] # reuses list's memory; nothing is copied
Transforming instead of changing
Instead of "load the object, mutate it, save it," you push data through a series of transformations:
order = %{id: 7, items: [10, 25, 5], status: :new}
paid =
order
|> Map.put(:total, Enum.sum(order.items))
|> Map.put(:status, :paid)
# %{id: 7, items: [10, 25, 5], status: :paid, total: 40}
# (iex may print the keys in a different order; map key order isn't guaranteed)
For nested data, put_in and update_in save you from rebuilding each level by hand:
config = %{db: %{host: "localhost", port: 5432}}
put_in(config, [:db, :port], 5433)
update_in(config, [:db, :port], &(&1 + 1))
Coming from Java: Imagine every class were a
record, every collection wereList.copyOf(...), and every setter were a "wither" that returns a copy. The difference is that Elixir makes this cheap and idiomatic. Persistent data structures share memory, so you don't pay for a full copy each time.
Practice
- Make a map
%{balance: 100}. Write three lines that deposit 50, withdraw 30, and deposit 10, rebinding each time. Then do the same thing as one pipeline. - Show that a closure captures the old value: bind a variable, capture it in an anonymous function, rebind the variable, and call the function.
- Given
%{user: %{address: %{city: "Kochi"}}}, change the city to"Phoenix"withput_in.
Self-check
- What's the difference between rebinding and mutation?
- Why don't BEAM processes need locks to read data?
- What is structural sharing, and why does it make immutability affordable?
Lesson 5: Modules and Functions
Goal: organize code into modules; use public and private functions, multiple clauses, guards, default arguments, anonymous functions, the capture operator &, and the pipe |>.
Modules, def and defp
defmodule Greeter do
def hello(name), do: greeting() <> ", " <> name <> "!"
def shout(name) do
name
|> hello()
|> String.upcase()
end
defp greeting, do: "Hello" # private: only callable inside Greeter
end
Greeter.hello("Unni") # "Hello, Unni!"
Greeter.shout("Unni") # "HELLO, UNNI!"
There are two ways to write a function body: a one-liner with , do:, or a do ... end block. Both are the same thing.
Multiple clauses: pattern matching in function heads
A function can have several clauses, and Elixir picks the first one whose arguments match. This is how you'll write most of your branching logic.
defmodule Shape do
def area({:square, side}), do: side * side
def area({:rect, w, h}), do: w * h
def area({:circle, r}), do: 3.14159 * r * r
end
Shape.area({:rect, 3, 4}) # 12
Guards
Guards (when ...) add conditions on top of a pattern. Only a limited set of functions is allowed in guards, such as is_integer/1, comparisons, and rem/2, which keeps them fast and side-effect free.
defmodule Age do
def category(n) when is_integer(n) and n < 13, do: :child
def category(n) when is_integer(n) and n < 20, do: :teen
def category(n) when is_integer(n), do: :adult
end
Age.category(15) # :teen
Default arguments
defmodule Hello do
def greet(name, greeting \\ "Hello"), do: "#{greeting}, #{name}"
end
Hello.greet("Unni") # "Hello, Unni"
Hello.greet("Unni", "Namaste") # "Namaste, Unni"
Anonymous functions and the capture operator
double = fn x -> x * 2 end
double.(5) # 10. Note the dot when you call it.
Enum.map([1, 2, 3], &(&1 * 2)) # shorthand: &1 is the first argument
Enum.map(["a", "b"], &String.upcase/1) # capture a named function
upcase = &String.upcase/1
upcase.("hi") # "HI"
The pipe |>
a |> f(b) means f(a, b): the value on the left becomes the first argument of the function on the right. That's why Elixir libraries put the "main" data first.
" hello elixir world "
|> String.trim()
|> String.split()
|> Enum.map(&String.capitalize/1)
|> Enum.join(" ")
# "Hello Elixir World"
Coming from Java: A module is like a
finalclass with onlystaticmethods and no fields. Data and behavior are kept separate on purpose. Multiple clauses replace overloading plus a lot ofif/instanceof, and they dispatch on values and shapes, not just types. The pipe reads like a fluent builder chain, but it works with any function.
Practice
- Write
Temperature.to_fahrenheit/1andto_celsius/1, then pipe a value through both and check you get it back. - Write
FizzBuzz.say/1using only multiple clauses and guards (noif,case, orcond). - Rewrite
Enum.map(list, fn x -> x + 1 end)with the&shorthand. - Write
Slug.make/1that turns" My First Elixir Post "into"my-first-elixir-post"using a pipeline.
Self-check
- What's the difference between
defanddefp? - Why do anonymous functions need a dot to be called (
f.(x))? - What does
&String.upcase/1mean? - Which argument does
|>fill in?
Lesson 6: Recursion, Enum and Stream
Goal: loop the functional way: head/tail recursion, tail calls and accumulators. Learn when to use Enum instead of writing your own recursion, and how Stream makes things lazy.
Elixir has no for (int i = 0; ...) and no while. You can't increment a counter in place, because nothing is mutable. So repetition is done with recursion, or more often with Enum, which does the recursion for you.
Head | tail recursion
Every list is either empty [] or a head plus a tail, [head | tail]. Write one clause for each case:
defmodule MyList do
def sum([]), do: 0
def sum([head | tail]), do: head + sum(tail)
end
MyList.sum([1, 2, 3]) # 6
Tail calls and accumulators
The version above has to keep each head + ... waiting on the stack. If the recursive call is the very last thing a function does, the BEAM reuses the stack frame (tail-call optimization), so the recursion can go as deep as it needs to. You get there by carrying the running result in an accumulator:
defmodule MyList2 do
def sum(list), do: do_sum(list, 0)
defp do_sum([], acc), do: acc
defp do_sum([head | tail], acc), do: do_sum(tail, acc + head)
end
MyList2.sum(Enum.to_list(1..1_000_000)) # 500000500000
When you build a list with an accumulator, you prepend (which is fast) and reverse once at the end:
defmodule Doubler do
def double_all(list), do: do_double(list, [])
defp do_double([], acc), do: Enum.reverse(acc)
defp do_double([h | t], acc), do: do_double(t, [h * 2 | acc])
end
The same idea gives you a loop that runs forever, which is exactly how processes will work in Lesson 7:
defmodule Countdown do
def run(0), do: IO.puts("Liftoff!")
def run(n) when n > 0 do
IO.puts(n)
run(n - 1)
end
end
Coming from Java: The JVM doesn't do tail-call optimization, so deep recursion in Java leads to a
StackOverflowError. On the BEAM, a tail-recursive function is as safe as awhileloop. Body recursion (like the firstsum) is also fine for normal-sized lists, so don't twist your code just to make everything tail recursive.
Enum vs. recursion
In practice, use Enum first: map, filter, reduce, group_by, frequencies, chunk_every, zip, and so on. Write your own recursion when the shape of the problem doesn't fit (walking a tree, parsing, a process loop) or when you want to learn how things work underneath.
Enum.frequencies(~w(a b a c b a)) # %{"a" => 3, "b" => 2, "c" => 1}
Enum.group_by(1..6, &rem(&1, 3)) # %{0 => [3, 6], 1 => [1, 4], 2 => [2, 5]}
The for comprehension is another handy option. It looks like a loop but it's still an expression that returns a list:
for x <- 1..5, rem(x, 2) == 1, do: x * x # [1, 9, 25]
Stream: lazy evaluation
Enum is eager: every step builds a whole new list. Stream builds a recipe, and nothing runs until something like Enum.take or Enum.to_list pulls values through.
1..1_000_000
|> Stream.map(&(&1 * 3))
|> Stream.filter(&(rem(&1, 2) == 0))
|> Enum.take(3)
# [6, 12, 18]. Only the first few elements are ever computed.
Stream.iterate(1, &(&1 * 2)) |> Enum.take(5) # [1, 2, 4, 8, 16] from an infinite stream
Streams are great for big files: File.stream!("big.log") |> Stream.filter(&String.contains?(&1, "ERROR")) |> Enum.count() reads the file line by line instead of loading all of it into memory.
Coming from Java:
Streamis the closest thing tojava.util.stream: lazy, composable, and run by a terminal operation (Enum.*here,collect/countin Java).Enumis the eager version that Java doesn't have.
Practice
- Write
MyList.length/1andMyList.max/1with recursion, then a tail-recursivelengthwith an accumulator. - Write
MyList.map/2, which takes a list and a function, recursively. Compare it withEnum.map/2. - Use
Stream.iterate/2andEnum.take_while/2to list the powers of 2 below 1,000. - Count word frequencies in a sentence:
String.split/1followed byEnum.frequencies/1.
Self-check
- What are the two cases every list recursion needs to handle?
- What makes a call a tail call, and why does that matter on the BEAM?
- Why do accumulator-based list builders end with
Enum.reverse/1? - When does
StreambeatEnum?
Lesson 7: Processes
Goal: create processes, send and receive messages, understand mailboxes, and use links and monitors to find out when another process dies. Along the way, see why processes are cheap enough to use everywhere.
spawn and self()
self() # the PID of the current process (your iex shell), e.g. #PID<0.110.0>
pid = spawn(fn -> IO.puts("Hello from #{inspect(self())}") end)
Process.sleep(10) # give it a moment to run
Process.alive?(pid) # false. It finished its work and exited.
send and receive
Every process has a mailbox. send/2 drops a message in it and returns immediately. receive waits for a message that matches one of its patterns.
parent = self()
spawn(fn -> send(parent, {:greeting, "hi from the child"}) end)
receive do
{:greeting, msg} -> IO.puts("Got: #{msg}")
after
1_000 -> IO.puts("Nothing arrived within 1 second")
end
Messages wait in the mailbox until someone receives them. In iex, flush() prints and empties the shell's mailbox:
send(self(), :a)
send(self(), :b)
Process.info(self(), :message_queue_len) # {:message_queue_len, 2}
flush() # prints :a and :b
A process that keeps state
A process holds state by calling itself recursively with the new state. It's the tail recursion from Lesson 6, with a receive in the middle:
defmodule CounterLoop do
def start(initial), do: spawn(fn -> loop(initial) end)
defp loop(count) do
receive do
:increment ->
loop(count + 1)
{:get, caller} ->
send(caller, {:count, count})
loop(count)
end
end
end
pid = CounterLoop.start(0)
send(pid, :increment)
send(pid, :increment)
send(pid, {:get, self()})
receive do
{:count, n} -> n # 2
end
Here's the important idea: the state lives inside the process, and the only way to read or change it is by sending a message. Messages are handled one at a time, so you never get a race condition.
Links and monitors
- A link (
spawn_link/1orProcess.link/1) ties two processes together in both directions. If one crashes, the other gets an exit signal and, by default, crashes too. Supervisors are built on links. - A monitor (
Process.monitor/1) works in one direction only. The watcher gets a:DOWNmessage and is not affected otherwise.
pid = spawn(fn -> Process.sleep(100) end)
ref = Process.monitor(pid)
receive do
{:DOWN, ^ref, :process, ^pid, reason} -> IO.puts("Exited: #{inspect(reason)}")
end
# Exited: :normal
spawn_link(fn -> raise "boom" end)
# The error also takes down the linked iex shell process. iex prints the
# error and starts a fresh shell, so earlier variables are gone.
Why processes are cheap
A BEAM process starts at about 2.6 KB of memory, is scheduled by the VM (not the OS), and has its own small heap that's garbage-collected separately. That means short GC pauses for each process instead of pauses that stop the whole world. Try this:
pids = for _ <- 1..100_000, do: spawn(fn -> Process.sleep(10_000) end)
length(pids) # 100000, in well under a second
:erlang.system_info(:process_limit) # 1048576 by default
Coming from Java: A process is closer to an actor (think Akka) than to a thread. It's an isolated unit with a mailbox, its own state, and no shared memory. Compared with a
ThreadPoolExecutorandConcurrentHashMap, you don't share state and protect it with locks; you give the state to one process and send that process messages.
Practice
- Spawn a process that waits for
{:square, n, caller}and replies{:result, n * n}. Call it fromiex. - Extend
CounterLoopwith a:resetmessage. - Monitor a process that does
raise "oops"and look at thereasonyou receive. - Spawn 10,000 processes that each send
{:done, i}back to you, then receive all of the messages withEnum.map(1..10_000, fn _ -> receive do {:done, i} -> i end end).
Self-check
- What does
send/2return, and does it wait for a reply? - Where does a process keep its state?
- What's the difference between a link and a monitor?
- Why can't a crash in one process corrupt another process's data?
Lesson 8: Gentle OTP
Goal: write a small GenServer, put it under a Supervisor, watch "let it crash" happen in real life, and wire it all into a mix project.
OTP is Erlang's standard library of proven patterns. The two you'll use first are GenServer (the CounterLoop from Lesson 7, done properly) and Supervisor (a process that restarts other processes).
GenServer: a counter
A GenServer module has two halves. The client API consists of ordinary functions that other code calls. The server callbacks run inside the GenServer process.
defmodule Counter do
use GenServer
# Client API
def start_link(initial) do
GenServer.start_link(__MODULE__, initial, name: __MODULE__)
end
def increment, do: GenServer.cast(__MODULE__, :increment)
def value, do: GenServer.call(__MODULE__, :value)
# Server callbacks
@impl true
def init(initial), do: {:ok, initial}
@impl true
def handle_cast(:increment, count), do: {:noreply, count + 1}
@impl true
def handle_call(:value, _from, count), do: {:reply, count, count}
end
callis synchronous: it waits for a reply (5-second timeout by default).castis asynchronous: fire and forget.- Each callback returns the new state, and the GenServer handles the receive loop for you.
name: __MODULE__registers the process under the nameCounter, so you don't have to pass a PID around.
Supervisor and restart strategies
children = [{Counter, 0}] # means: call Counter.start_link(0)
{:ok, _sup} = Supervisor.start_link(children, strategy: :one_for_one)
Counter.increment()
Counter.value() # 1
Process.whereis(Counter) |> Process.exit(:kill) # simulate a crash
Process.sleep(50)
Counter.value() # 0. Restarted fresh by the supervisor.
| Strategy | When a child dies... |
|---|---|
:one_for_one |
Only that child is restarted (the most common choice) |
:one_for_all |
All children are restarted (they depend on each other) |
:rest_for_one |
The child plus every child started after it is restarted |
By default, a supervisor gives up (and crashes itself) if there are more than 3 restarts in 5 seconds. That escalates the problem one level up the tree.
The "let it crash" idea
You don't try to handle every possible error. You:
- Handle the errors you expect (for example,
{:error, :not_found}is a normal result). - Let unexpected errors crash the process.
- Rely on the supervisor to restart it in a clean, known state.
This works because processes are isolated (Lesson 7) and data is immutable (Lesson 4). A crash can't leave corrupted shared state behind. The trade-off is that a restarted process loses its in-memory state, so keep anything precious somewhere durable.
Coming from Java: A supervision tree is a bit like Spring's application context combined with a Kubernetes liveness probe, but inside your app and acting in microseconds. A named GenServer feels like a singleton
@Servicebean that owns its state and handles requests one at a time, with nosynchronizedneeded.
Tying it together in a mix project
mix new counter_app --sup
cd counter_app
--sup generates lib/counter_app/application.ex, which starts a supervisor when your app boots. Save the Counter module as lib/counter_app/counter.ex, rename it CounterApp.Counter, and add it to the children list:
# lib/counter_app/application.ex
def start(_type, _args) do
children = [
{CounterApp.Counter, 0}
]
opts = [strategy: :one_for_one, name: CounterApp.Supervisor]
Supervisor.start_link(children, opts)
end
iex -S mix
iex> CounterApp.Counter.increment()
iex> CounterApp.Counter.value()
1
Add a test in test/counter_app_test.exs:
defmodule CounterAppTest do
use ExUnit.Case
test "increment adds one" do
before = CounterApp.Counter.value()
CounterApp.Counter.increment()
assert CounterApp.Counter.value() == before + 1
end
end
Run it with mix test. (The cast and the call that follows it come from the same process, so the BEAM guarantees they arrive in order.)
Practice
- Add
decrement/0andreset/0toCounter. - Turn
Counterinto a small key–value store,KV, withput(key, value)(a cast) andget(key)(a call), using a map as the state. - Kill your
KVprocess withProcess.exit(pid, :kill)while it's supervised. Confirm that it comes back, and notice that its data is gone. - Start two children under one supervisor, try
:one_for_all, and watch both restart when you kill one.
Self-check
- What's the difference between
GenServer.callandGenServer.cast? - What does every GenServer callback have to return?
- What does
{Counter, 0}in a children list actually do? - What does "let it crash" depend on to be safe?
Mini Capstone: A Supervised To-Do Server
Goal: build a small app that uses everything from the course: structs and maps, pattern matching, immutability, a pipeline, a GenServer, and a supervisor. Plan for two or three relaxed sessions.
The design follows a pattern you'll appreciate as an architect: a functional core (pure functions on plain data, easy to test) inside a thin process boundary (the GenServer that holds state).
mix new todo --sup
cd todo
Step 1: the pure core (lib/todo/list.ex)
defmodule Todo.List do
defstruct next_id: 1, items: %{}
def new, do: %Todo.List{}
def add(%Todo.List{} = list, title) do
item = %{id: list.next_id, title: title, done: false}
%{list | items: Map.put(list.items, list.next_id, item), next_id: list.next_id + 1}
end
def complete(%Todo.List{} = list, id) do
case Map.fetch(list.items, id) do
{:ok, item} -> {:ok, %{list | items: Map.put(list.items, id, %{item | done: true})}}
:error -> {:error, :not_found}
end
end
def all(%Todo.List{} = list), do: list.items |> Map.values() |> Enum.sort_by(& &1.id)
end
Try it in iex -S mix, without any processes yet: Todo.List.new() |> Todo.List.add("Read chapter 1") |> Todo.List.all().
Step 2: the server (lib/todo/server.ex)
defmodule Todo.Server do
use GenServer
def start_link(_opts), do: GenServer.start_link(__MODULE__, :ok, name: __MODULE__)
def add(title), do: GenServer.call(__MODULE__, {:add, title})
def complete(id), do: GenServer.call(__MODULE__, {:complete, id})
def all, do: GenServer.call(__MODULE__, :all)
@impl true
def init(:ok), do: {:ok, Todo.List.new()}
@impl true
def handle_call({:add, title}, _from, list), do: {:reply, :ok, Todo.List.add(list, title)}
def handle_call({:complete, id}, _from, list) do
case Todo.List.complete(list, id) do
{:ok, new_list} -> {:reply, :ok, new_list}
{:error, _} = error -> {:reply, error, list}
end
end
def handle_call(:all, _from, list), do: {:reply, Todo.List.all(list), list}
end
Step 3: supervise it (lib/todo/application.ex)
children = [Todo.Server] # same as {Todo.Server, []}
iex> Todo.Server.add("Learn pattern matching")
iex> Todo.Server.add("Write a GenServer")
iex> Todo.Server.complete(1)
iex> Todo.Server.all()
iex> Todo.Server.complete(99) # {:error, :not_found}
iex> Process.whereis(Todo.Server) |> Process.exit(:kill)
iex> Todo.Server.all() # [] (restarted with an empty list)
Step 4: tests and stretch goals
- Write ExUnit tests for
Todo.List. Because it's pure, you don't need any processes for these. - Add
delete/1andrename/2. - Persistence: after every change, save the state with
File.write!("todo.bin", :erlang.term_to_binary(list)), and load it ininit/1so it survives restarts. - Variant: build a word-count server instead.
WordCount.add_text("the cat the hat")updates counts usingEnum.frequencies/1andMap.merge/3, andWordCount.top(3)returns the most common words.
Java → Elixir Quick Map
A one-page translation table for when your Java instincts kick in.
| Java | Elixir | Notes |
|---|---|---|
class with fields and methods |
defmodule with functions + a defstruct |
Data and behaviour are kept apart |
new User("Unni") |
%User{name: "Unni"} |
Structs are just maps with a name |
user.setName("U") |
%{user \| name: "U"} |
Returns a new struct |
null / Optional<T> |
nil / {:ok, v} and {:error, r} |
Tagged tuples are the norm |
try/catch for flow control |
case / with on result tuples |
Exceptions are for the truly exceptional |
switch (Java 21 patterns) |
case, multi-clause functions |
Matches any data shape |
for / while loops |
Enum, for comprehensions, recursion |
No mutable counters |
java.util.stream |
Stream (lazy) and Enum (eager) |
Same pipeline mindset |
interface |
@behaviour / defprotocol |
Callbacks / polymorphism on data type |
Thread, ExecutorService |
spawn, Task, GenServer |
Millions of cheap processes |
synchronized, locks |
One process owns the state | Messages are handled one at a time |
Spring @Service singleton |
Named GenServer |
Owns its state |
| Spring Boot app context | Application + supervision tree |
Restarts crashed parts |
| Maven / Gradle | mix + mix.exs, Hex packages |
mix deps.get |
| JUnit | ExUnit (mix test) |
Built in |
jshell |
iex |
With built-in docs: h Enum |
Here's a small side-by-side. Java:
List<String> names = users.stream()
.filter(u -> u.getAge() >= 18)
.map(u -> u.getName().toUpperCase())
.collect(Collectors.toList());
Elixir:
users = [%{name: "ada", age: 36}, %{name: "tim", age: 12}]
users
|> Enum.filter(&(&1.age >= 18))
|> Enum.map(&String.upcase(&1.name))
# ["ADA"]
And polymorphism with a protocol, the Elixir answer to "an interface that types implement":
defprotocol Describe do
def describe(value)
end
defimpl Describe, for: Integer do
def describe(n), do: "the number #{n}"
end
defimpl Describe, for: Map do
def describe(m), do: "a map with #{map_size(m)} keys"
end
Describe.describe(42) # "the number 42"
Describe.describe(%{a: 1}) # "a map with 1 keys"
Unlike a Java interface, you can implement a protocol for a type you don't own (even Integer), without touching its source.
Answers to the Practice Exercises
Try each exercise first, then compare. There's usually more than one good answer. Every answer below was run on Elixir 1.18.
Lesson 1
7 / 2 # 3.5
div(7, 2) # 3
rem(7, 2) # 1
first = "Ada"
last = "Lovelace"
"#{last}, #{first}" # "Lovelace, Ada"
last <> ", " <> first # "Lovelace, Ada"
String.length("naïve") # 5
byte_size("naïve") # 6
String.length("👍") # 1
byte_size("👍") # 4
String.reverse("elixir") # "rixile"
String.length counts what a human sees as characters (graphemes); byte_size counts UTF-8 bytes, and non-ASCII characters take 2–4 bytes.
Lesson 2
langs = ["Java", "Python", "SQL"]
langs = ["Elixir" | langs]
length(langs) # 4
cities = %{"Phoenix" => 1_650_000, "Kochi" => 680_000, "Mumbai" => 12_440_000}
cities
|> Enum.filter(fn {_city, pop} -> pop > 1_000_000 end)
|> Map.new()
# %{"Mumbai" => 12440000, "Phoenix" => 1650000}
defmodule Movie do
defstruct [:title, :year, watched: false]
end
m = %Movie{title: "Arrival", year: 2016}
seen = %{m | watched: true}
seen.watched # true
Enum.reduce(["ant", "giraffe", "cat"], fn word, longest ->
if String.length(word) > String.length(longest), do: word, else: longest
end)
# "giraffe"
(Enum.max_by(list, &String.length/1) does the last one in a single call.)
Lesson 3
{tag, name, age} = {:user, "Unni", 40}
name # "Unni"
{:user, n, _} = {:user, "Unni", 40}
n # "Unni"
describe = fn result ->
case result do
{:ok, value} -> "Success: #{value}"
{:error, reason} -> "Failed because #{reason}"
end
end
describe.({:ok, 42}) # "Success: 42"
describe.({:error, :timeout}) # "Failed because timeout"
fizz = fn n ->
cond do
rem(n, 15) == 0 -> "FizzBuzz"
rem(n, 3) == 0 -> "Fizz"
rem(n, 5) == 0 -> "Buzz"
true -> n
end
end
fizz.(15) # "FizzBuzz"
fizz.(9) # "Fizz"
fizz.(7) # 7
defmodule Signup2 do
def run(params) do
with {:ok, name} <- Map.fetch(params, "name"),
{:ok, age} <- Map.fetch(params, "age"),
true <- age >= 18,
{:ok, email} <- Map.fetch(params, "email"),
true <- String.contains?(email, "@") do
{:ok, "Welcome, #{name}"}
else
:error -> {:error, :missing_field}
false -> {:error, :invalid}
end
end
end
Signup2.run(%{"name" => "Unni", "age" => 40, "email" => "u@x.com"}) # {:ok, "Welcome, Unni"}
Signup2.run(%{"name" => "Unni", "age" => 40, "email" => "nope"}) # {:error, :invalid}
Lesson 4
acct = %{balance: 100}
acct = %{acct | balance: acct.balance + 50}
acct = %{acct | balance: acct.balance - 30}
acct = %{acct | balance: acct.balance + 10}
acct.balance # 130
deposit = fn a, amt -> %{a | balance: a.balance + amt} end
withdraw = fn a, amt -> %{a | balance: a.balance - amt} end
%{balance: 100}
|> deposit.(50)
|> withdraw.(30)
|> deposit.(10)
# %{balance: 130}
x = 1
f = fn -> x end
x = 2
f.() # 1 (the closure kept the old value)
x # 2
data = %{user: %{address: %{city: "Kochi"}}}
put_in(data, [:user, :address, :city], "Phoenix")
# %{user: %{address: %{city: "Phoenix"}}}
Lesson 5
defmodule Temperature do
def to_fahrenheit(c), do: c * 9 / 5 + 32
def to_celsius(f), do: (f - 32) * 5 / 9
end
25 |> Temperature.to_fahrenheit() |> Temperature.to_celsius() # 25.0
defmodule FizzBuzz do
def say(n) when rem(n, 15) == 0, do: "FizzBuzz"
def say(n) when rem(n, 3) == 0, do: "Fizz"
def say(n) when rem(n, 5) == 0, do: "Buzz"
def say(n), do: n
end
Enum.map(1..15, &FizzBuzz.say/1) |> List.last() # "FizzBuzz"
Enum.map([1, 2, 3], &(&1 + 1)) # [2, 3, 4]
defmodule Slug do
def make(text) do
text
|> String.trim()
|> String.downcase()
|> String.split()
|> Enum.join("-")
end
end
Slug.make(" My First Elixir Post ") # "my-first-elixir-post"
Lesson 6
defmodule MyListX do
import Kernel, except: [length: 1] # we define our own length/1
def length([]), do: 0
def length([_ | tail]), do: 1 + length(tail)
def max([only]), do: only
def max([head | tail]), do: Kernel.max(head, max(tail))
def tr_length(list), do: do_length(list, 0)
defp do_length([], acc), do: acc
defp do_length([_ | t], acc), do: do_length(t, acc + 1)
def map([], _f), do: []
def map([h | t], f), do: [f.(h) | map(t, f)]
end
MyListX.length([4, 5, 6]) # 3
MyListX.max([3, 9, 2]) # 9
MyListX.tr_length(Enum.to_list(1..100_000)) # 100000
MyListX.map([1, 2, 3], &(&1 * 10)) # [10, 20, 30]
Stream.iterate(1, &(&1 * 2)) |> Enum.take_while(&(&1 < 1000))
# [1, 2, 4, 8, 16, 32, 64, 128, 256, 512]
"the cat and the hat" |> String.split() |> Enum.frequencies()
# %{"and" => 1, "cat" => 1, "hat" => 1, "the" => 2}
Lesson 7
squarer = spawn(fn ->
receive do
{:square, n, caller} -> send(caller, {:result, n * n})
end
end)
send(squarer, {:square, 7, self()})
receive do
{:result, r} -> r # 49
end
defmodule CounterLoop2 do
def start(initial), do: spawn(fn -> loop(initial) end)
defp loop(count) do
receive do
:increment -> loop(count + 1)
:reset -> loop(0)
{:get, caller} ->
send(caller, {:count, count})
loop(count)
end
end
end
c = CounterLoop2.start(5)
send(c, :reset)
send(c, {:get, self()})
receive do
{:count, n} -> n # 0
end
{pid, ref} = spawn_monitor(fn -> raise "oops" end)
receive do
{:DOWN, ^ref, :process, ^pid, {%RuntimeError{message: msg}, _stack}} -> msg # "oops"
end
me = self()
for i <- 1..10_000, do: spawn(fn -> send(me, {:done, i}) end)
results = Enum.map(1..10_000, fn _ -> receive do {:done, i} -> i end end)
length(results) # 10000
(spawn_monitor/1 spawns and monitors in one atomic step, so you can't miss a crash that happens instantly. The crash also prints an error report; that's expected.)
Lesson 8
defmodule KV do
use GenServer
def start_link(_), do: GenServer.start_link(__MODULE__, %{}, name: __MODULE__)
def put(key, value), do: GenServer.cast(__MODULE__, {:put, key, value})
def get(key), do: GenServer.call(__MODULE__, {:get, key})
@impl true
def init(state), do: {:ok, state}
@impl true
def handle_cast({:put, k, v}, state), do: {:noreply, Map.put(state, k, v)}
@impl true
def handle_call({:get, k}, _from, state), do: {:reply, Map.get(state, k), state}
end
{:ok, _} = Supervisor.start_link([KV], strategy: :one_for_one)
KV.put(:lang, "Elixir")
KV.get(:lang) # "Elixir"
old = Process.whereis(KV)
Process.exit(old, :kill)
Process.sleep(50)
Process.whereis(KV) != old # true (a new process)
KV.get(:lang) # nil (state was lost)
For decrement/0 and reset/0 on Counter, add def decrement, do: GenServer.cast(__MODULE__, :decrement) and def reset, do: GenServer.cast(__MODULE__, :reset) on the client side, plus def handle_cast(:decrement, c), do: {:noreply, c - 1} and def handle_cast(:reset, _c), do: {:noreply, 0} on the server side. For exercise 4, give two GenServers distinct names (each needs its own child_spec id), start them with strategy: :one_for_all, kill one, and compare both PIDs before and after: both change.
Suggested Schedule
Roughly three or four relaxed sessions a week (30–60 minutes each). The dates assume you start on Monday, October 19, 2026; shift them freely.
| Week | Dates | Sessions |
|---|---|---|
| Week 0 (optional) | Oct 14–18 | Install Elixir, play in iex, read the Intro, run hello.exs |
| Week 1 | Oct 19–25 | Lesson 1 (types) · Lesson 2 (collections) · Lesson 3 (pattern matching) |
| Week 2 | Oct 26–Nov 1 | Lesson 4 (immutability) · Lesson 5 (modules and functions) · Lesson 6 (recursion, Enum, Stream) |
| Week 3 | Nov 2–8 | Lesson 7 (processes) · Lesson 8 (OTP) · Capstone, steps 1–3 |
| Bonus | Nov 9 onward | Capstone stretch goals, the first Exercism exercises, pick a book |
If a lesson feels heavy, especially 3 (pattern matching) or 7 (processes), give it two sessions. It's a hobby, so there's no reason to rush.
Cheat Sheet
| Concept | Syntax | Note |
|---|---|---|
| Atom | :ok |
Constant whose name is its value |
| String | "Hi #{name}" |
UTF-8 binary; <> concatenates |
| Charlist | ~c"hi" |
List of code points (mostly for Erlang interop) |
| List | [1, 2, 3] |
Linked list; prepend with [0 \| list] |
| Tuple | {:ok, value} |
Fixed size; tagged results |
| Keyword list | [trim: true] |
Options; list of {atom, value} |
| Map | %{a: 1, "b" => 2} |
Update: %{m \| a: 9} |
| Struct | %User{name: "U"} |
Defined with defstruct in a module |
| Range | 1..10, 1..10//2 |
Enumerable; // sets the step |
| Match | {:ok, x} = result |
= matches; _ ignores |
| Pin | ^x |
Match against x's current value |
| List pattern | [head \| tail] |
Split the first element from the rest |
| case | case v do p -> ... end |
First matching pattern wins |
| cond | cond do c -> ... end |
First true condition wins |
| with | with {:ok, a} <- f() do ... end |
Chain steps that can fail |
| Function | def f(x), do: x |
defp = private |
| Guard | def f(x) when x > 0 |
Limited, side-effect-free checks |
| Default arg | def f(x \\ 1) |
Default value |
| Anonymous fn | fn x -> x * 2 end |
Call with f.(x) |
| Capture | &(&1 * 2), &String.upcase/1 |
Short anonymous functions |
| Pipe | x \|> f() \|> g(y) |
Becomes g(f(x), y) |
| Comprehension | for x <- list, do: x * 2 |
Can filter and collect into other types |
| Spawn | spawn(fn -> ... end) |
Returns a PID |
| Message | send(pid, msg) |
Asynchronous, never blocks |
| Receive | receive do p -> ... after 1000 -> ... end |
Waits for a matching message |
| GenServer | GenServer.call/2, GenServer.cast/2 |
Sync request / async message |
| Supervisor | Supervisor.start_link(children, strategy: :one_for_one) |
Restarts children |
| Docs in iex | h Enum.map |
Built-in help |
Next Steps and Resources
Books
- Programming Elixir ≥ 1.6 by Dave Thomas (Pragmatic Bookshelf). A fun, opinionated tour from the author of The Pragmatic Programmer. A great "second pass" over everything in this guide.
- Elixir in Action, 3rd edition by Saša Jurić (Manning). Excellent on the BEAM, processes, and OTP, and probably the best fit for an architect's mindset.
- The Little Elixir & OTP Guidebook by Benjamin Tan Wei Hao (Manning). Short and hands-on, focused on OTP and supervisors through small projects.
- Learn Functional Programming with Elixir by Ulisses Almeida (Pragmatic Bookshelf). Gentle and focused on the functional mindset: recursion, higher-order functions, and immutability.
Free and online
- Joy of Elixir (joyofelixir.com). A free, friendly book for beginners.
- The official guides at elixir-lang.org (Getting Started, then "Mix and OTP"). Concise and always up to date. The "Mix and OTP" guide builds a key–value store, which makes a perfect follow-up to Lesson 8.
- Exercism's Elixir track (exercism.org/tracks/elixir). Free practice exercises that teach concepts in order, with optional mentoring.
- HexDocs (hexdocs.pm). Documentation for Elixir and every package. It's what
hiniexshows.
After this guide, some fun hobby directions: Phoenix LiveView (interactive web apps with almost no JavaScript), Livebook (Elixir notebooks, like Jupyter), Nerves (Elixir on a Raspberry Pi), and ETS/Registry to take your capstone further.
Progress Checklist
- ☐ Installed Elixir and Erlang;
elixir --versionworks - ☐ Ran
hello.exsand created a project withmix new - ☐ Lesson 1: Core syntax and basic types
- ☐ Lesson 2: Collections and Enum basics
- ☐ Lesson 3: Pattern matching, case, cond, if, with
- ☐ Lesson 4: Immutability
- ☐ Lesson 5: Modules, functions, guards, capture, pipe
- ☐ Lesson 6: Recursion, Enum and Stream
- ☐ Lesson 7: Processes, messages, links and monitors
- ☐ Lesson 8: GenServer and Supervisor
- ☐ Capstone: to-do server running under a supervisor
- ☐ Capstone: tests written for
Todo.List - ☐ Capstone stretch goal (persistence or word-count variant)
- ☐ Solved first 5 Exercism Elixir exercises
- ☐ Picked a book for the next stage
✨ Made by Lumen for Unni ✨