Wizard with Elixir potion

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:

  1. 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.
  2. 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.
  3. 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.
  4. "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 synchronized blocks, and no ConcurrentModificationException. 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)

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.exs plays the role of pom.xml or build.gradle. It's plain Elixir code, though, not XML. Dependencies go in the deps function, and mix deps.get downloads 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 int vs long vs BigInteger. One integer type grows as needed. / never truncates, so use div/2 when 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 cleaner String.format. A Java String is a sequence of UTF-16 chars; an Elixir string is UTF-8 bytes, and the String module 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: nil is like null, but calling a function on it doesn't throw a surprise NPE deep in a call chain. Most functions just don't match on nil, and the error message tells you exactly what value showed up. You'll also use nil much less, because {:ok, value} / {:error, reason} tuples fill the role of Optional and 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

  1. In iex, work out 7 / 2, div(7, 2), and rem(7, 2). Before you hit Enter, predict whether each one returns an integer or a float.
  2. Make first = "Ada" and last = "Lovelace", then build "Lovelace, Ada" two ways: with interpolation and with <>.
  3. Try String.length and byte_size on "naïve" and on an emoji such as "👍". Why are the numbers different?
  4. Use h String to browse the docs and find a function that reverses a string. Try it.

Self-check

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: Enum is the Stream API without the .stream() and .collect(...) ceremony. Enum.map returns a list right away (it's eager). Elixir's lazy equivalent is Stream, covered in Lesson 6.

Practice

  1. Make a list of your three favorite languages. Prepend "Elixir" and get its length.
  2. Build a map of three cities and their populations. Use Enum.filter to keep the ones over a million, then use Map.new to turn the result back into a map.
  3. Define a Movie struct with title, year, and watched (default false). Make one, then make a copy with watched: true.
  4. Use Enum.reduce to find the longest string in ["ant", "giraffe", "cat"].

Self-check

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: case is like Java 21's pattern-matching switch, except it can take apart any data shape, not just records. with replaces a stack of nested if (x != null) checks or a chain of Optional.flatMap calls: the happy path reads top to bottom, and the failures are handled in one place.

Practice

  1. Destructure {:user, "Unni", 40} into three variables. Then write a pattern that only matches when the first element is :user.
  2. Write a case that takes a {:ok, value} or {:error, reason} tuple and returns a friendly string.
  3. Use cond to write FizzBuzz for one number ("Fizz", "Buzz", "FizzBuzz", or the number itself).
  4. Extend Signup.run/1 so it also requires an "email" that contains "@".

Self-check

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 were List.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

  1. 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.
  2. Show that a closure captures the old value: bind a variable, capture it in an anonymous function, rebind the variable, and call the function.
  3. Given %{user: %{address: %{city: "Kochi"}}}, change the city to "Phoenix" with put_in.

Self-check

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 final class with only static methods and no fields. Data and behavior are kept separate on purpose. Multiple clauses replace overloading plus a lot of if/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

  1. Write Temperature.to_fahrenheit/1 and to_celsius/1, then pipe a value through both and check you get it back.
  2. Write FizzBuzz.say/1 using only multiple clauses and guards (no if, case, or cond).
  3. Rewrite Enum.map(list, fn x -> x + 1 end) with the & shorthand.
  4. Write Slug.make/1 that turns " My First Elixir Post " into "my-first-elixir-post" using a pipeline.

Self-check

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 a while loop. Body recursion (like the first sum) 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: Stream is the closest thing to java.util.stream: lazy, composable, and run by a terminal operation (Enum.* here, collect/count in Java). Enum is the eager version that Java doesn't have.

Practice

  1. Write MyList.length/1 and MyList.max/1 with recursion, then a tail-recursive length with an accumulator.
  2. Write MyList.map/2, which takes a list and a function, recursively. Compare it with Enum.map/2.
  3. Use Stream.iterate/2 and Enum.take_while/2 to list the powers of 2 below 1,000.
  4. Count word frequencies in a sentence: String.split/1 followed by Enum.frequencies/1.

Self-check

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.

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 ThreadPoolExecutor and ConcurrentHashMap, you don't share state and protect it with locks; you give the state to one process and send that process messages.

Practice

  1. Spawn a process that waits for {:square, n, caller} and replies {:result, n * n}. Call it from iex.
  2. Extend CounterLoop with a :reset message.
  3. Monitor a process that does raise "oops" and look at the reason you receive.
  4. Spawn 10,000 processes that each send {:done, i} back to you, then receive all of the messages with Enum.map(1..10_000, fn _ -> receive do {:done, i} -> i end end).

Self-check

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

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:

  1. Handle the errors you expect (for example, {:error, :not_found} is a normal result).
  2. Let unexpected errors crash the process.
  3. 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 @Service bean that owns its state and handles requests one at a time, with no synchronized needed.

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

  1. Add decrement/0 and reset/0 to Counter.
  2. Turn Counter into a small key–value store, KV, with put(key, value) (a cast) and get(key) (a call), using a map as the state.
  3. Kill your KV process with Process.exit(pid, :kill) while it's supervised. Confirm that it comes back, and notice that its data is gone.
  4. Start two children under one supervisor, try :one_for_all, and watch both restart when you kill one.

Self-check

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

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

Free and online

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

✨ Made by Lumen for Unni ✨

↑