Beginner

Ready to master Elixir and build real applications? This comprehensive beginner tutorial takes you from basic syntax to OTP fundamentals, covering 0-60% of what you need to write production-quality Elixir code.

Prerequisites

Required:

Recommended:

  • Basic programming experience (any language)
  • Understanding of functions and data structures
  • Familiarity with command line
  • Git installed (for version control)

What You’ll Learn

This tutorial provides comprehensive coverage of Elixir fundamentals:

Core Language (Sections 1-7):

  • Advanced pattern matching with guards and pin operator
  • Complete data structure tour (lists, tuples, maps, keyword lists, structs)
  • Functions and modules in depth
  • Pipe operator mastery
  • Control flow patterns
  • Enum and Stream modules
  • String and binary manipulation

Testing and Quality (Section 8):

  • ExUnit testing framework
  • Test-driven development patterns
  • Documentation with ExDoc

OTP Basics (Section 9):

  • Processes and message passing
  • GenServer introduction
  • Error handling with supervisors

Real Projects (Section 10):

  • Complete working applications
  • Best practices and patterns

Learning Path

  %% Color Palette: Blue #0173B2, Orange #DE8F05, Teal #029E73, Purple #CC78BC, Brown #CA9161
graph TD
    Start[Start: Quick Start Done] --> Pattern[Advanced Pattern Matching]
    Pattern --> Data[Data Structures Mastery]
    Data --> Functions[Functions & Modules Deep Dive]
    Functions --> Control[Control Flow]
    Control --> Enum[Enum & Stream]
    Enum --> Strings[Strings & Binaries]
    Strings --> Testing[Testing with ExUnit]
    Testing --> OTP[OTP Basics]
    OTP --> Projects[Build Real Projects]
    Projects --> Ready[Ready for Intermediate!]

    style Start fill:#0173B2
    style Ready fill:#029E73
    style Pattern fill:#DE8F05
    style Data fill:#DE8F05
    style Testing fill:#CC78BC
    style OTP fill:#CC78BC

Coverage

This tutorial covers 0-60% of Elixir - everything you need to build complete applications independently.

After completing this tutorial, you will:

  • Write idiomatic Elixir code following best practices
  • Use all major data structures effectively
  • Build tested modules with clear APIs
  • Understand basic concurrency with processes
  • Debug and troubleshoot Elixir applications
  • Read and understand real-world Elixir codebases

What’s covered:

  • Complete syntax and semantics
  • All built-in data structures
  • Testing and documentation
  • Basic process management
  • Real-world patterns and practices

What’s NOT covered (Intermediate/Advanced):

  • Phoenix web framework
  • GenServer advanced patterns
  • Distributed systems
  • Metaprogramming and macros
  • Performance optimization

Section 1: Advanced Pattern Matching

1.1 Pattern Matching Review

Pattern matching is Elixir’s most powerful feature. Let’s go deeper than the Quick Start.

{:ok, value} = {:ok, 42}
value  # => 42

{:ok, {x, y}} = {:ok, {10, 20}}
x  # => 10
y  # => 20

[first, second | rest] = [1, 2, 3, 4, 5]
first   # => 1
second  # => 2
rest    # => [3, 4, 5]

%{name: name, age: age} = %{name: "Alice", age: 28, city: "Portland"}
name  # => "Alice"
age   # => 28

1.2 The Pin Operator (^)

The pin operator ^ uses the existing value of a variable instead of rebinding.

x = 1
x = 2  # x is now 2
x  # => 2

x = 1
^x = 1  # Works! 1 matches 1
^x = 2  # ** (MatchError) - 2 doesn't match 1

x = 42
[^x, y, z] = [42, 10, 20]  # Works! First element matches 42
y  # => 10
z  # => 20

[^x, y, z] = [99, 10, 20]  # ** (MatchError) - 99 doesn't match 42

defmodule Matcher do
  def check(value, ^value), do: "Same!"
  def check(_value1, _value2), do: "Different"
end

Matcher.check(5, 5)  # => "Same!"
Matcher.check(5, 3)  # => "Different"

numbers = [1, 2, 3, 4, 5]
target = 3
for ^target <- numbers, do: :found

When to use pin:

  • Matching against existing variables
  • Validating specific values in patterns
  • Function clauses with specific constraints

1.3 Guards

Guards add conditions to pattern matching. They make function clauses more specific.

Basic guards:

defmodule Number do
  def describe(n) when n < 0, do: "negative"
  def describe(0), do: "zero"
  def describe(n) when n > 0, do: "positive"
end

Number.describe(-5)  # => "negative"
Number.describe(0)   # => "zero"
Number.describe(10)  # => "positive"

def adult?(age) when is_integer(age) and age >= 18, do: true
def adult?(_), do: false

adult?(25)    # => true
adult?(15)    # => false
adult?("25")  # => false (not an integer)

def vowel?(letter) when letter == "a" or letter == "e" or letter == "i" do
  true
end
def vowel?(_), do: false

vowel?("a")  # => true
vowel?("b")  # => false

Allowed in guards:

is_atom(x)
is_binary(x)
is_boolean(x)
is_float(x)
is_integer(x)
is_list(x)
is_map(x)
is_number(x)
is_tuple(x)

==, !=, ===, !==, <, >, <=, >=

and, or, not

+, -, *, /

in, length, map_size, tuple_size

Complex guard examples:

defmodule Validator do
  # Validate email-like string
  def valid_email?(email) when is_binary(email) and byte_size(email) > 3 do
    String.contains?(email, "@")
  end
  def valid_email?(_), do: false

  # Check if number in range
  def in_range?(n, min, max) when is_number(n) and n >= min and n <= max do
    true
  end
  def in_range?(_, _, _), do: false

  # Validate non-empty list
  def non_empty_list?(list) when is_list(list) and length(list) > 0 do
    true
  end
  def non_empty_list?(_), do: false
end

Validator.valid_email?("alice@example.com")  # => true
Validator.in_range?(5, 1, 10)                # => true
Validator.non_empty_list?([1, 2, 3])         # => true

1.4 Pattern Matching in Case and Cond

Case expressions:

result = {:ok, %{name: "Alice", age: 28}}

message = case result do
  {:ok, %{name: name, age: age}} when age >= 18 ->
    "Adult user: #{name}"

  {:ok, %{name: name}} ->
    "User: #{name}"

  {:error, reason} ->
    "Error: #{reason}"

  _ ->
    "Unknown result"
end

defmodule HTTPClient do
  def handle_response(response) do
    case response do
      {:ok, %{status: 200, body: body}} ->
        {:ok, body}

      {:ok, %{status: 404}} ->
        {:error, :not_found}

      {:ok, %{status: 500}} ->
        {:error, :server_error}

      {:error, reason} ->
        {:error, {:network_error, reason}}
    end
  end
end

Cond expressions (multiple conditions):

defmodule Grade do
  def letter(score) do
    cond do
      score >= 90 -> "A"
      score >= 80 -> "B"
      score >= 70 -> "C"
      score >= 60 -> "D"
      true -> "F"  # Default case (always true)
    end
  end
end

Grade.letter(95)  # => "A"
Grade.letter(75)  # => "C"
Grade.letter(55)  # => "F"

defmodule Weather do
  def advice(temp, weather) do
    cond do
      temp > 30 and weather == :sunny ->
        "Hot and sunny - stay hydrated!"

      temp > 20 and weather == :rainy ->
        "Warm rain - bring umbrella"

      temp < 10 ->
        "Cold - wear a jacket"

      true ->
        "Pleasant weather - enjoy!"
    end
  end
end

1.5 Pattern Matching in Functions

Multiple function clauses:

defmodule Calculator do
  # Pattern match on operation and arguments
  def calc(:add, a, b), do: a + b
  def calc(:subtract, a, b), do: a - b
  def calc(:multiply, a, b), do: a * b
  def calc(:divide, _a, 0), do: {:error, :division_by_zero}
  def calc(:divide, a, b), do: {:ok, a / b}
end

Calculator.calc(:add, 5, 3)      # => 8
Calculator.calc(:divide, 10, 0)  # => {:error, :division_by_zero}

defmodule Formatter do
  def format({:user, name, age}) do
    "User: #{name} (#{age} years old)"
  end

  def format({:product, name, price}) do
    "Product: #{name} - $#{price}"
  end

  def format(data) when is_map(data) do
    "Map with #{map_size(data)} keys"
  end

  def format(data) when is_list(data) do
    "List with #{length(data)} elements"
  end
end

Formatter.format({:user, "Alice", 28})

Formatter.format(%{a: 1, b: 2})

Recursive patterns:

defmodule ListOps do
  # Sum with accumulator
  def sum(list), do: sum(list, 0)

  defp sum([], acc), do: acc
  defp sum([head | tail], acc) do
    sum(tail, acc + head)
  end

  # Flatten nested lists
  def flatten([]), do: []
  def flatten([head | tail]) when is_list(head) do
    flatten(head) ++ flatten(tail)
  end
  def flatten([head | tail]) do
    [head | flatten(tail)]
  end

  # Find element
  def find([], _target), do: nil
  def find([target | _tail], target), do: {:ok, target}
  def find([_head | tail], target), do: find(tail, target)
end

ListOps.sum([1, 2, 3, 4, 5])           # => 15
ListOps.flatten([[1, 2], [3, [4, 5]]]) # => [1, 2, 3, 4, 5]
ListOps.find([1, 2, 3, 4], 3)          # => {:ok, 3}

Section 2: Data Structures Deep Dive

2.1 Lists In Depth

Lists are linked lists - understand their performance characteristics.

List operations:

list = [1, 2, 3, 4, 5]

[0 | list]  # => [0, 1, 2, 3, 4, 5]

list ++ [6]  # => [1, 2, 3, 4, 5, 6]

[1, 2] ++ [3, 4]  # => [1, 2, 3, 4]

[1, 2, 3, 4] -- [2, 4]  # => [1, 3]

Enum.at([1, 2, 3, 4], 2)  # => 3

hd([1, 2, 3])  # => 1
tl([1, 2, 3])  # => [2, 3]

2 in [1, 2, 3]  # => true

length([1, 2, 3])  # => 3

When to use lists:

  • Building collections incrementally (prepend)
  • Processing data in order (recursion)
  • Stack-like operations (push/pop from front)

When NOT to use lists:

  • Random access by index (use tuples or maps)
  • Frequent appending (use other structures)

List comprehensions mastery:

for n <- 1..10, do: n * n

for n <- 1..20,
    rem(n, 2) == 0,    # Only even
    rem(n, 3) == 0,    # Only divisible by 3
    do: n

for x <- [1, 2, 3],
    y <- [:a, :b],
    do: {x, y}

users = [
  %{name: "Alice", role: :admin},
  %{name: "Bob", role: :user},
  %{name: "Carol", role: :admin}
]

for %{name: name, role: :admin} <- users, do: name

for {k, v} <- %{a: 1, b: 2, c: 3}, into: %{}, do: {v, k}

for n <- 1..5, reduce: 0 do
  sum -> sum + n
end

2.2 Tuples

Tuples store fixed-size collections with fast access.

tuple = {:ok, "Success", 42}

elem(tuple, 0)  # => :ok
elem(tuple, 1)  # => "Success"
elem(tuple, 2)  # => 42

tuple_size(tuple)  # => 3

put_elem(tuple, 1, "Updated")

{:ok, message, code} = {:ok, "Success", 200}
message  # => "Success"
code     # => 200

Common tuple patterns:

def divide(a, b) when b != 0, do: {:ok, a / b}
def divide(_a, 0), do: {:error, :division_by_zero}

{:user, "Alice", 28}
{:product, "Widget", 9.99}

{10, 20}  # {x, y}
{10, 20, 30}  # {x, y, z}

When to use tuples:

  • Fixed-size collections
  • Return values with status
  • Pattern matching scenarios
  • Performance-critical index access

2.3 Maps - The Workhorse Data Structure

Maps are key-value stores with fast access.

Creating and accessing maps:

person = %{name: "Alice", age: 28, city: "Portland"}

person[:name]   # => "Alice"
person.name     # => "Alice" (only works with atom keys)
person[:email]  # => nil

Map.get(person, :name)              # => "Alice"
Map.get(person, :email, "unknown")  # => "unknown" (default)

data = %{"name" => "Bob", "age" => 35}
data["name"]  # => "Bob"

mixed = %{:atom_key => 1, "string_key" => 2}

key = :dynamic
%{key => "value"}  # => %{dynamic: "value"}

Updating maps:

person = %{name: "Alice", age: 28}

person = %{person | age: 29}

person = Map.put(person, :email, "alice@example.com")

person = %{person | name: "Alicia", age: 30}

defaults = %{role: "user", active: true}
person = Map.merge(defaults, person)

person = Map.delete(person, :email)

Nested maps:

user = %{
  name: "Alice",
  contact: %{
    email: "alice@example.com",
    phone: "555-1234"
  },
  address: %{
    street: "123 Main St",
    city: "Portland"
  }
}

user[:contact][:email]  # => "alice@example.com"
get_in(user, [:contact, :email])  # => "alice@example.com"

user = put_in(user, [:contact, :email], "newemail@example.com")
user = update_in(user, [:contact, :phone], &("1-" <> &1))

{old_email, user} = pop_in(user, [:contact, :email])

Map functions:

map = %{a: 1, b: 2, c: 3}

Map.keys(map)    # => [:a, :b, :c]
Map.values(map)  # => [1, 2, 3]

Map.has_key?(map, :a)  # => true

Map.update!(map, :a, &(&1 * 10))  # => %{a: 10, b: 2, c: 3}

Map.take(map, [:a, :c])  # => %{a: 1, c: 3}
Map.drop(map, [:b])      # => %{a: 1, c: 3}

When to use maps:

  • Dynamic data with named fields
  • JSON-like data
  • Configuration
  • Fast key lookup

2.4 Keyword Lists

Keyword lists are lists of {key, value} tuples with atom keys.

opts = [size: 10, color: "red", border: true]

opts[:size]   # => 10
opts[:color]  # => "red"

Keyword.get(opts, :size)              # => 10
Keyword.get(opts, :missing, "default") # => "default"

opts = Keyword.put(opts, :size, 20)

opts = [size: 10, size: 20]  # Valid!
opts[:size]  # => 10 (first occurrence)

Keyword.merge([a: 1, b: 2], [b: 3, c: 4])

When to use keyword lists:

  • Function options (most common use case)
  • Order matters
  • Duplicate keys needed

Common pattern - function options:

defmodule Database do
  def query(sql, opts \\ []) do
    timeout = Keyword.get(opts, :timeout, 5000)
    pool = Keyword.get(opts, :pool, :default)

    # Use timeout and pool...
    "Querying with timeout: #{timeout}, pool: #{pool}"
  end
end

Database.query("SELECT * FROM users")

Database.query("SELECT * FROM users", timeout: 10000, pool: :replica)

2.5 Structs

Structs are named maps with defined keys and compile-time guarantees.

Defining structs:

defmodule User do
  defstruct name: "", email: "", age: 0, role: :user

  # With required keys
  # defstruct [:name, :email, age: 0, role: :user]
end

user = %User{name: "Alice", email: "alice@example.com", age: 28}

user.name   # => "Alice"
user[:age]  # => 28

user = %{user | age: 29}

%User{name: name, age: age} = user
name  # => "Alice"

Why structs over maps:

person = %{name: "Alice", agee: 28}  # Typo! No error

user = %User{name: "Alice", agee: 28}

defmodule Service do
  def process(%User{} = user) do
    # Type-checked at compile time
    "Processing user: #{user.name}"
  end
end

Struct functions:

defmodule User do
  defstruct [:name, :email, age: 0]

  def new(name, email) do
    %User{name: name, email: email}
  end

  def adult?(%User{age: age}) when age >= 18, do: true
  def adult?(_), do: false

  def update_email(%User{} = user, new_email) do
    %{user | email: new_email}
  end
end

user = User.new("Alice", "alice@example.com")
User.adult?(user)  # => false (age defaults to 0)

user = %{user | age: 28}
User.adult?(user)  # => true

When to use structs:

  • Domain models (User, Product, Order, etc.)
  • Data with known fields
  • Type checking needed
  • Clear data contracts

2.6 Ranges

Ranges represent sequences of numbers.

range = 1..10
range = 1..10//2  # Step of 2 (Elixir 1.12+)

5 in 1..10   # => true
15 in 1..10  # => false

Enum.to_list(1..5)  # => [1, 2, 3, 4, 5]

for n <- 1..10, do: n * n

10..1  # Descending

?a..?z  # => 97..122 (ASCII values)

2.7 Choosing the Right Data Structure

shopping_cart = ["apple", "banana", "orange"]

coordinate = {10.5, 20.3}
result = {:ok, data}

user = %{name: "Alice", email: "alice@example.com"}

opts = [timeout: 5000, retry: 3]

user = %User{name: "Alice", age: 28}

for page <- 1..10, do: fetch_page(page)

Section 3: Functions and Modules Mastery

3.1 Anonymous Functions Deep Dive

Capture operator (&):

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

Enum.map([1, 2, 3], &(&1 * 2))

Enum.map(["hello", "world"], &String.upcase/1)

add = &(&1 + &2)
add.(5, 3)  # => 8

multiply_by_10 = &(&1 * 10)
multiply_by_10.(5)  # => 50

Closures:

defmodule Counter do
  def create(initial) do
    fn -> initial end
  end

  def create_incrementer(start) do
    fn amount -> start + amount end
  end
end

get_ten = Counter.create(10)
get_ten.()  # => 10

inc_from_5 = Counter.create_incrementer(5)
inc_from_5.(3)  # => 8
inc_from_5.(10) # => 15

defmodule Filter do
  def greater_than(threshold) do
    fn value -> value > threshold end
  end
end

greater_than_10 = Filter.greater_than(10)
Enum.filter([5, 15, 8, 20, 12], greater_than_10)

3.2 Named Functions Advanced

Multiple clauses with pattern matching:

defmodule FizzBuzz do
  def convert(n) when rem(n, 15) == 0, do: "FizzBuzz"
  def convert(n) when rem(n, 3) == 0, do: "Fizz"
  def convert(n) when rem(n, 5) == 0, do: "Buzz"
  def convert(n), do: n
end

Enum.map(1..15, &FizzBuzz.convert/1)

Default arguments:

defmodule Greeter do
  def hello(name \\ "World", punctuation \\ "!") do
    "Hello, #{name}#{punctuation}"
  end
end

Greeter.hello()                # => "Hello, World!"
Greeter.hello("Alice")         # => "Hello, Alice!"
Greeter.hello("Bob", ".")      # => "Hello, Bob."

Private functions:

defmodule Calculator do
  def calculate(operation, a, b) do
    case operation do
      :add -> add(a, b)
      :multiply -> multiply(a, b)
      _ -> {:error, :unknown_operation}
    end
  end

  defp add(a, b), do: a + b
  defp multiply(a, b), do: a * b
end

Calculator.calculate(:add, 5, 3)  # => 8
Calculator.add(5, 3)  # ** (UndefinedFunctionError) - private!

Function arity:

defmodule Math do
  # Different arities are different functions
  def add(a, b), do: a + b
  def add(a, b, c), do: a + b + c
end

add_two = &Math.add/2
add_three = &Math.add/3

add_two.(5, 3)      # => 8
add_three.(5, 3, 2) # => 10

3.3 Modules Organization

Module attributes:

defmodule Config do
  @default_timeout 5000
  @retry_count 3

  def timeout, do: @default_timeout
  def retries, do: @retry_count

  # Module attribute as constant
  @max_connections 100

  def max_connections, do: @max_connections
end

Config.timeout()  # => 5000

Nested modules:

defmodule MyApp do
  defmodule User do
    defstruct [:name, :email]

    def new(name, email) do
      %User{name: name, email: email}
    end
  end

  defmodule Product do
    defstruct [:name, :price]

    def new(name, price) do
      %Product{name: name, price: price}
    end
  end
end

user = MyApp.User.new("Alice", "alice@example.com")
product = MyApp.Product.new("Widget", 9.99)

alias MyApp.User
user = User.new("Bob", "bob@example.com")

Import, alias, and require:

defmodule Example do
  # Alias - shorten module name
  alias MyApp.User
  alias MyApp.Product, as: Prod

  # Import - bring functions into scope
  import Enum, only: [map: 2, filter: 2]

  # Require - needed for macros
  require Logger

  def process_users(users) do
    # Can use User instead of MyApp.User
    users
    |> map(&User.display/1)
    |> filter(&User.active?/1)
  end
end

3.4 Pipe Operator Mastery

Advanced piping:

"hello"
|> String.upcase()
|> String.duplicate(3)

data
|> process()
|> then(&save_to_db(:users, &1))  # Pipe into second arg

{:ok, result} =
  data
  |> validate()
  |> transform()

[1, 2, 3, 4, 5]
|> Enum.map(&(&1 * 2))
|> IO.inspect(label: "After map")
|> Enum.filter(&(&1 > 5))
|> IO.inspect(label: "After filter")
|> Enum.sum()

Real-world pipeline:

defmodule DataProcessor do
  def process_csv(file_path) do
    file_path
    |> File.read!()
    |> String.split("\n")
    |> Enum.reject(&(&1 == ""))
    |> Enum.map(&String.split(&1, ","))
    |> Enum.map(&parse_row/1)
    |> Enum.filter(&valid_row?/1)
    |> Enum.map(&transform_row/1)
    |> save_to_database()
  end

  defp parse_row([name, age, city]) do
    %{name: name, age: String.to_integer(age), city: city}
  end

  defp valid_row?(%{age: age}), do: age >= 18

  defp transform_row(row) do
    Map.put(row, :status, :active)
  end

  defp save_to_database(rows) do
    # Save rows...
    {:ok, length(rows)}
  end
end

Section 4: Control Flow

4.1 If and Unless

if true do
  "Yes"
else
  "No"
end

if true, do: "Yes", else: "No"

unless false do
  "Executed"
end

result = {:ok, 42}
if {:ok, value} = result, do: value, else: nil

When to use if/unless:

  • Simple binary decisions
  • Guard-like conditions
  • Avoid for complex logic (use case/cond instead)

4.2 Case - Pattern Matching

result = {:ok, "data"}

case result do
  {:ok, data} -> "Success: #{data}"
  {:error, reason} -> "Error: #{reason}"
  _ -> "Unknown"
end

value = 15

case value do
  n when n < 0 -> "Negative"
  0 -> "Zero"
  n when n < 10 -> "Small positive"
  n when n < 100 -> "Medium positive"
  _ -> "Large positive"
end

case File.read("data.txt") do
  {:ok, content} ->
    case Jason.decode(content) do
      {:ok, data} -> process(data)
      {:error, _} -> {:error, :invalid_json}
    end

  {:error, reason} ->
    {:error, {:file_error, reason}}
end

4.3 Cond - Multiple Conditions

defmodule Weather do
  def advice(temp, humidity) do
    cond do
      temp > 35 -> "Extremely hot - stay indoors"
      temp > 30 and humidity > 80 -> "Hot and humid - drink water"
      temp > 25 -> "Warm and pleasant"
      temp > 15 -> "Cool - light jacket"
      temp > 5 -> "Cold - wear jacket"
      true -> "Very cold - bundle up"
    end
  end
end

4.4 With - Happy Path Pipeline

defmodule UserService do
  def create_user(params) do
    with {:ok, validated} <- validate(params),
         {:ok, user} <- insert_user(validated),
         {:ok, email_sent} <- send_welcome_email(user) do
      {:ok, user}
    else
      {:error, :validation_failed} ->
        {:error, "Invalid user data"}

      {:error, :database_error} ->
        {:error, "Could not save user"}

      {:error, :email_failed} ->
        {:error, "User created but email failed"}
    end
  end

  defp validate(%{name: name, email: email}) when byte_size(name) > 0 do
    if String.contains?(email, "@") do
      {:ok, %{name: name, email: email}}
    else
      {:error, :validation_failed}
    end
  end
  defp validate(_), do: {:error, :validation_failed}

  defp insert_user(data) do
    # Simulate database insert
    {:ok, Map.put(data, :id, :rand.uniform(1000))}
  end

  defp send_welcome_email(user) do
    # Simulate email sending
    {:ok, true}
  end
end

UserService.create_user(%{name: "Alice", email: "alice@example.com"})

Section 5: Enum and Stream

5.1 Enum Module - Collection Operations

Mapping and filtering:

Enum.map([1, 2, 3], &(&1 * 2))

Enum.filter([1, 2, 3, 4, 5], &rem(&1, 2) == 0)

Enum.reject([1, 2, 3, 4, 5], &rem(&1, 2) == 0)

[1, 2, 3, 4, 5]
|> Enum.filter(&rem(&1, 2) == 0)
|> Enum.map(&(&1 * &1))

Reducing:

Enum.sum([1, 2, 3, 4, 5])  # => 15

Enum.reduce([1, 2, 3, 4], 0, fn x, acc -> x + acc end)

["apple", "banana", "apricot"]
|> Enum.reduce(%{}, fn fruit, acc ->
  first_letter = String.first(fruit)
  Map.update(acc, first_letter, [fruit], &[fruit | &1])
end)

Sorting:

Enum.sort([3, 1, 4, 1, 5, 9])

Enum.sort([3, 1, 4], :desc)

users = [
  %{name: "Bob", age: 35},
  %{name: "Alice", age: 28},
  %{name: "Carol", age: 42}
]

Enum.sort_by(users, & &1.age)

Finding:

Enum.find([1, 2, 3, 4, 5], &(&1 > 3))

Enum.find([1, 2, 3], &(&1 > 10), :not_found)

Enum.at([1, 2, 3, 4], 2)

Enum.take([1, 2, 3, 4, 5], 3)  # => [1, 2, 3]
Enum.drop([1, 2, 3, 4, 5], 2)  # => [3, 4, 5]

Grouping and splitting:

users = [
  %{name: "Alice", city: "Portland"},
  %{name: "Bob", city: "Seattle"},
  %{name: "Carol", city: "Portland"}
]

Enum.group_by(users, & &1.city)

Enum.chunk_every([1, 2, 3, 4, 5, 6], 2)

Enum.split([1, 2, 3, 4, 5], 3)

5.2 Stream - Lazy Enumeration

Streams defer computation until needed - great for large datasets.

[1, 2, 3]
|> Enum.map(&(&1 * 2))
|> Enum.filter(&(&1 > 3))

[1, 2, 3]
|> Stream.map(&(&1 * 2))
|> Stream.filter(&(&1 > 3))
|> Enum.to_list()

Stream.iterate(0, &(&1 + 1))
|> Stream.take(10)
|> Enum.to_list()

Stream.cycle([:a, :b, :c])
|> Stream.take(7)
|> Enum.to_list()

File.stream!("large_file.csv")
|> Stream.map(&String.trim/1)
|> Stream.reject(&(&1 == ""))
|> Stream.map(&String.split(&1, ","))
|> Enum.take(100)  # Only process first 100 lines

When to use Stream:

  • Large or infinite collections
  • Multiple transformations (avoid intermediate lists)
  • Performance-critical pipelines
  • Processing files line by line

Section 6: Strings and Binaries

6.1 String Basics

str = "Hello, World!"

"Hello, " <> "World!"  # => "Hello, World!"

name = "Alice"
"Hello, #{name}!"  # => "Hello, Alice!"

String.length("Hello")  # => 5

String.upcase("hello")    # => "HELLO"
String.downcase("HELLO")  # => "hello"
String.capitalize("hello world")  # => "Hello world"

String.trim("  hello  ")  # => "hello"
String.trim_leading("  hello")  # => "hello"
String.trim_trailing("hello  ")  # => "hello"

6.2 String Operations

String.split("a,b,c", ",")  # => ["a", "b", "c"]
String.split("hello world")  # => ["hello", "world"]

Enum.join(["a", "b", "c"], ",")  # => "a,b,c"

String.replace("hello world", "world", "Elixir")

String.contains?("hello world", "world")  # => true
String.starts_with?("hello", "he")  # => true
String.ends_with?("world", "ld")  # => true

String.slice("hello", 0..2)  # => "hel"
String.slice("hello", 1, 3)  # => "ell"

String.reverse("hello")  # => "olleh"

6.3 Pattern Matching with Strings

"Hello " <> rest = "Hello World"
rest  # => "World"

<<head, rest::binary>> = "Hello"
head  # => 72 (ASCII 'H')
rest  # => "ello"

String.first("Hello")  # => "H"
String.last("Hello")   # => "o"

Section 7: Common Patterns and Idioms

7.1 Recursion Patterns

Accumulator pattern:

defmodule Math do
  # Public function
  def factorial(n) when n >= 0, do: factorial(n, 1)

  # Private recursive function with accumulator
  defp factorial(0, acc), do: acc
  defp factorial(n, acc) do
    factorial(n - 1, n * acc)
  end
end

Math.factorial(5)  # => 120

List processing:

defmodule ListProcessor do
  # Map with accumulator
  def my_map(list, fun), do: my_map(list, fun, [])

  defp my_map([], _fun, acc), do: Enum.reverse(acc)
  defp my_map([head | tail], fun, acc) do
    my_map(tail, fun, [fun.(head) | acc])
  end
end

ListProcessor.my_map([1, 2, 3], &(&1 * 2))

7.2 Error Handling Patterns

Tagged tuples:

defmodule FileHandler do
  def read_and_parse(path) do
    with {:ok, content} <- File.read(path),
         {:ok, data} <- Jason.decode(content) do
      {:ok, data}
    end
  end
end

case FileHandler.read_and_parse("data.json") do
  {:ok, data} -> process(data)
  {:error, _} -> handle_error()
end

Exceptions for truly exceptional cases:

defmodule BankAccount do
  # Normal operation - tagged tuple
  def withdraw(balance, amount) when amount <= balance do
    {:ok, balance - amount}
  end
  def withdraw(_balance, _amount), do: {:error, :insufficient_funds}

  # Exceptional case - raise
  def withdraw!(balance, amount) when amount <= balance do
    balance - amount
  end
  def withdraw!(_balance, _amount) do
    raise "Insufficient funds!"
  end
end

7.3 Building Module APIs

defmodule ShoppingCart do
  @moduledoc """
  Shopping cart implementation with items and totals.
  """

  defstruct items: [], total: 0.0

  @doc """
  Creates a new empty shopping cart.

  ## Examples

      iex> ShoppingCart.new()
      %ShoppingCart{items: [], total: 0.0}
  """
  def new, do: %ShoppingCart{}

  @doc """
  Adds an item to the cart.
  """
  def add_item(%ShoppingCart{items: items, total: total}, item, price) do
    %ShoppingCart{
      items: [item | items],
      total: total + price
    }
  end

  @doc """
  Returns the current total.
  """
  def get_total(%ShoppingCart{total: total}), do: total

  @doc """
  Lists all items in the cart.
  """
  def list_items(%ShoppingCart{items: items}), do: Enum.reverse(items)
end

Section 8: Testing with ExUnit

8.1 Basic Testing

defmodule CalculatorTest do
  use ExUnit.Case

  test "adds two numbers" do
    assert Calculator.add(2, 3) == 5
  end

  test "subtracts two numbers" do
    assert Calculator.subtract(5, 3) == 2
  end

  test "handles division by zero" do
    assert Calculator.divide(10, 0) == {:error, :division_by_zero}
  end
end

Run tests:

mix test

8.2 Setup and Context

defmodule UserServiceTest do
  use ExUnit.Case

  # Run before each test
  setup do
    user = %User{name: "Test User", email: "test@example.com"}
    {:ok, user: user}
  end

  test "validates user email", %{user: user} do
    assert UserService.valid_email?(user)
  end

  test "formats user display", %{user: user} do
    assert UserService.display(user) == "Test User <test@example.com>"
  end
end

8.3 Assertions

assert 1 + 1 == 2
refute 1 + 1 == 3

assert {:ok, value} = some_function()

assert_raise ArithmeticError, fn ->
  1 / 0
end

assert_in_delta 0.1 + 0.2, 0.3, 0.0001

Section 9: OTP Basics - Processes

9.1 Spawning Processes

pid = spawn(fn -> IO.puts("Hello from process!") end)

send(pid, {:hello, "World"})

receive do
  {:hello, msg} -> IO.puts("Received: #{msg}")
after
  1000 -> IO.puts("Timeout!")
end

9.2 Process Communication

defmodule Messenger do
  def listen do
    receive do
      {:say, msg} ->
        IO.puts("Message: #{msg}")
        listen()  # Recursive call to continue listening

      :stop ->
        IO.puts("Stopping")
    end
  end
end

pid = spawn(&Messenger.listen/0)

send(pid, {:say, "Hello"})
send(pid, {:say, "World"})
send(pid, :stop)

9.3 GenServer Introduction

GenServer provides standard client-server pattern.

defmodule Counter do
  use GenServer

  # Client API
  def start_link(initial_value) do
    GenServer.start_link(__MODULE__, initial_value, name: __MODULE__)
  end

  def increment do
    GenServer.call(__MODULE__, :increment)
  end

  def get_value do
    GenServer.call(__MODULE__, :get_value)
  end

  # Server Callbacks
  def init(initial_value) do
    {:ok, initial_value}
  end

  def handle_call(:increment, _from, state) do
    {:reply, state + 1, state + 1}
  end

  def handle_call(:get_value, _from, state) do
    {:reply, state, state}
  end
end

{:ok, _pid} = Counter.start_link(0)
Counter.increment()  # => 1
Counter.increment()  # => 2
Counter.get_value()  # => 2

Section 10: Hands-On Projects

Project 1: Todo List Manager

defmodule TodoList do
  defstruct items: [], next_id: 1

  def new, do: %TodoList{}

  def add_item(%TodoList{items: items, next_id: id}, description) do
    item = %{id: id, description: description, done: false}
    %TodoList{
      items: [item | items],
      next_id: id + 1
    }
  end

  def mark_done(%TodoList{items: items} = list, id) do
    updated_items = Enum.map(items, fn item ->
      if item.id == id do
        %{item | done: true}
      else
        item
      end
    end)

    %{list | items: updated_items}
  end

  def list_pending(%TodoList{items: items}) do
    items
    |> Enum.reject(& &1.done)
    |> Enum.reverse()
  end

  def list_completed(%TodoList{items: items}) do
    items
    |> Enum.filter(& &1.done)
    |> Enum.reverse()
  end
end

list = TodoList.new()
list = TodoList.add_item(list, "Buy groceries")
list = TodoList.add_item(list, "Write code")
list = TodoList.mark_done(list, 1)
TodoList.list_pending(list)

Project 2: CSV Parser

defmodule CSVParser do
  def parse(file_path) do
    file_path
    |> File.stream!()
    |> Stream.map(&String.trim/1)
    |> Stream.reject(&(&1 == ""))
    |> Enum.map(&parse_line/1)
    |> to_maps()
  end

  defp parse_line(line) do
    line
    |> String.split(",")
    |> Enum.map(&String.trim/1)
  end

  defp to_maps([headers | rows]) do
    Enum.map(rows, fn row ->
      headers
      |> Enum.zip(row)
      |> Map.new()
    end)
  end
end


CSVParser.parse("data.csv")

Project 3: Bank Account with GenServer

defmodule BankAccount do
  use GenServer

  # Client API
  def start_link(initial_balance) do
    GenServer.start_link(__MODULE__, initial_balance)
  end

  def deposit(pid, amount) when amount > 0 do
    GenServer.call(pid, {:deposit, amount})
  end

  def withdraw(pid, amount) when amount > 0 do
    GenServer.call(pid, {:withdraw, amount})
  end

  def balance(pid) do
    GenServer.call(pid, :balance)
  end

  def transaction_history(pid) do
    GenServer.call(pid, :history)
  end

  # Server Callbacks
  def init(initial_balance) do
    state = %{
      balance: initial_balance,
      transactions: []
    }
    {:ok, state}
  end

  def handle_call({:deposit, amount}, _from, state) do
    new_balance = state.balance + amount
    transaction = {:deposit, amount, DateTime.utc_now()}

    new_state = %{
      balance: new_balance,
      transactions: [transaction | state.transactions]
    }

    {:reply, {:ok, new_balance}, new_state}
  end

  def handle_call({:withdraw, amount}, _from, state) do
    if amount <= state.balance do
      new_balance = state.balance - amount
      transaction = {:withdraw, amount, DateTime.utc_now()}

      new_state = %{
        balance: new_balance,
        transactions: [transaction | state.transactions]
      }

      {:reply, {:ok, new_balance}, new_state}
    else
      {:reply, {:error, :insufficient_funds}, state}
    end
  end

  def handle_call(:balance, _from, state) do
    {:reply, state.balance, state}
  end

  def handle_call(:history, _from, state) do
    {:reply, Enum.reverse(state.transactions), state}
  end
end

{:ok, account} = BankAccount.start_link(1000.0)
BankAccount.deposit(account, 500.0)    # => {:ok, 1500.0}
BankAccount.withdraw(account, 200.0)   # => {:ok, 1300.0}
BankAccount.balance(account)           # => 1300.0
BankAccount.transaction_history(account)

Section 11: Protocols and Behaviours

11.1 Protocols - Polymorphism in Elixir

Protocols allow you to define functionality that can be implemented for different data types.

Defining a protocol:

defprotocol Drawable do
  @doc "Draws the given shape"
  def draw(shape)
end

defimpl Drawable, for: Circle do
  def draw(%Circle{radius: radius}) do
    "Drawing circle with radius #{radius}"
  end
end

defimpl Drawable, for: Rectangle do
  def draw(%Rectangle{width: width, height: height}) do
    "Drawing rectangle #{width}x#{height}"
  end
end

defmodule Circle do
  defstruct [:radius]
end

defmodule Rectangle do
  defstruct [:width, :height]
end

circle = %Circle{radius: 5}
rectangle = %Rectangle{width: 10, height: 20}

Drawable.draw(circle)      # => "Drawing circle with radius 5"
Drawable.draw(rectangle)   # => "Drawing rectangle 10x20"

Built-in protocols:

defimpl String.Chars, for: User do
  def to_string(%User{name: name, email: email}) do
    "#{name} <#{email}>"
  end
end

user = %User{name: "Alice", email: "alice@example.com"}
to_string(user)  # => "Alice <alice@example.com>"
"User: #{user}"  # => "User: Alice <alice@example.com>"

defimpl Inspect, for: User do
  def inspect(%User{name: name}, _opts) do
    "#User<#{name}>"
  end
end

user  # => #User<Alice> (in IEx)

defmodule MyRange do
  defstruct [:from, :to]
end

defimpl Enumerable, for: MyRange do
  def count(%MyRange{from: from, to: to}) do
    {:ok, to - from + 1}
  end

  def member?(%MyRange{from: from, to: to}, value) do
    {:ok, value >= from and value <= to}
  end

  def reduce(%MyRange{from: from, to: to}, acc, fun) do
    Enum.to_list(from..to)
    |> Enumerable.List.reduce(acc, fun)
  end

  def slice(_range), do: {:error, __MODULE__}
end

range = %MyRange{from: 1, to: 5}
Enum.to_list(range)  # => [1, 2, 3, 4, 5]
Enum.sum(range)      # => 15
3 in range           # => true

Real-world protocol example:

defprotocol Serializable do
  @doc "Serializes data to JSON-compatible format"
  def serialize(data)
end

defimpl Serializable, for: User do
  def serialize(%User{name: name, email: email, age: age}) do
    %{
      type: "user",
      data: %{
        name: name,
        email: email,
        age: age
      }
    }
  end
end

defimpl Serializable, for: Product do
  def serialize(%Product{name: name, price: price}) do
    %{
      type: "product",
      data: %{
        name: name,
        price: price
      }
    }
  end
end

user = %User{name: "Alice", email: "alice@example.com", age: 28}
user
|> Serializable.serialize()
|> Jason.encode!()

11.2 Behaviours - Defining Contracts

Behaviours define a contract that modules must implement.

Defining a behaviour:

defmodule Parser do
  @callback parse(String.t()) :: {:ok, term()} | {:error, String.t()}
  @callback format(term()) :: String.t()
end

defmodule JSONParser do
  @behaviour Parser

  def parse(json_string) do
    case Jason.decode(json_string) do
      {:ok, data} -> {:ok, data}
      {:error, _} -> {:error, "Invalid JSON"}
    end
  end

  def format(data) do
    Jason.encode!(data)
  end
end

defmodule CSVParser do
  @behaviour Parser

  def parse(csv_string) do
    rows = String.split(csv_string, "\n")
    {:ok, rows}
  end

  def format(rows) when is_list(rows) do
    Enum.join(rows, "\n")
  end
end

defmodule DataProcessor do
  def process(data, parser_module) do
    with {:ok, parsed} <- parser_module.parse(data) do
      # Process parsed data...
      formatted = parser_module.format(parsed)
      {:ok, formatted}
    end
  end
end

GenServer is a behaviour:

defmodule MyServer do
  use GenServer

  # Required callbacks
  def init(args), do: {:ok, args}
  def handle_call(_msg, _from, state), do: {:reply, :ok, state}
  def handle_cast(_msg, state), do: {:noreply, state}
end

11.3 Protocols vs Behaviours

When to use protocols:

  • Polymorphic operations on different types
  • Extending functionality for existing types
  • Type-specific implementations

When to use behaviours:

  • Define module contracts
  • Ensure modules implement required callbacks
  • Framework/library design

Section 12: File I/O and System Interaction

12.1 Reading and Writing Files

Reading files:

{:ok, content} = File.read("data.txt")

case File.read("data.txt") do
  {:ok, content} -> IO.puts(content)
  {:error, reason} -> IO.puts("Error: #{reason}")
end

content = File.read!("data.txt")

File.stream!("large_file.txt")
|> Stream.map(&String.trim/1)
|> Enum.each(&IO.puts/1)

Writing files:

File.write("output.txt", "Hello, World!")

File.write!("output.txt", "Hello, World!")

File.write("log.txt", "New entry\n", [:append])

lines = ["Line 1", "Line 2", "Line 3"]
content = Enum.join(lines, "\n")
File.write("output.txt", content)

File operations:

File.exists?("data.txt")  # => true/false

{:ok, info} = File.stat("data.txt")
info.size  # File size in bytes

File.cp("source.txt", "destination.txt")

File.rm("temp.txt")

File.mkdir("new_folder")

{:ok, files} = File.ls(".")
files  # => ["file1.txt", "file2.txt", ...]

Path.wildcard("**/*.ex")

12.2 Path Operations

Path.join(["home", "user", "documents", "file.txt"])

Path.dirname("/home/user/file.txt")  # => "/home/user"

Path.basename("/home/user/file.txt")  # => "file.txt"

Path.extname("file.txt")  # => ".txt"

Path.expand("../file.txt")  # => "/full/path/to/file.txt"

Path.absname?("/home/user")  # => true
Path.absname?("relative")    # => false

12.3 System Commands

System.cmd("ls", ["-la"])

System.get_env("HOME")  # => "/home/user"

System.put_env("MY_VAR", "value")

File.cwd!()  # => "/current/directory"

File.cd("new_directory")

Section 13: Comprehensions Advanced

13.1 Complex Comprehensions

Nested comprehensions:

for x <- 1..3, y <- 1..3 do
  {x, y}
end

for x <- 1..5,
    y <- 1..5,
    x + y == 6,
    do: {x, y}

users = [
  %{name: "Alice", posts: [%{title: "Post 1"}, %{title: "Post 2"}]},
  %{name: "Bob", posts: [%{title: "Post 3"}]}
]

for %{name: name, posts: posts} <- users,
    %{title: title} <- posts,
    do: {name, title}

Comprehensions with reduce:

users = [
  %{id: 1, name: "Alice"},
  %{id: 2, name: "Bob"}
]

for %{id: id, name: name} <- users, into: %{} do
  {id, name}
end

for n <- 1..10, reduce: 0 do
  sum -> sum + n
end

for word <- ["hello", "world"], reduce: "" do
  acc -> acc <> String.upcase(word) <> " "
end

13.2 Bitstring Comprehensions

for <<byte <- "Hello">>, do: byte

for <<byte <- "Hello">>, byte > 100, do: byte

for <<char <- "hello">>, into: "", do: <<char - 32>>

Section 14: Debugging and Troubleshooting

14.1 IO.inspect - Your Best Friend

[1, 2, 3, 4, 5]
|> Enum.map(&(&1 * 2))
|> IO.inspect(label: "After map")
|> Enum.filter(&(&1 > 5))
|> IO.inspect(label: "After filter")
|> Enum.sum()

result = [1, 2, 3]
         |> IO.inspect(label: "Input")
         |> Enum.sum()
         |> IO.inspect(label: "Result")

14.2 IEx.pry - Interactive Debugging

defmodule Debug do
  def complex_function(data) do
    result = transform(data)
    require IEx; IEx.pry()  # Execution pauses here
    result |> finalize()
  end

  defp transform(data), do: data * 2
  defp finalize(data), do: data + 10
end

Debug.complex_function(5)

14.3 Common Error Messages

{:ok, value} = {:error, :reason}

defmodule Math do
  def double(n) when is_number(n), do: n * 2
end

Math.double("not a number")

Enum.at([1, 2, 3], "invalid index")

%{a: 1} |> Map.fetch!(:b)

1 / 0

Section 15: Mix Projects Deep Dive

15.1 Project Configuration

defmodule MyApp.MixProject do
  use Mix.Project

  def project do
    [
      app: :my_app,
      version: "0.1.0",
      elixir: "~> 1.14",
      start_permanent: Mix.env() == :prod,
      deps: deps(),
      # Additional options
      elixirc_paths: elixirc_paths(Mix.env()),
      test_coverage: [tool: ExCoveralls],
      preferred_cli_env: [
        coveralls: :test,
        "coveralls.detail": :test
      ]
    ]
  end

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

  defp deps do
    [
      {:jason, "~> 1.4"},
      {:httpoison, "~> 1.8"},
      {:ex_doc, "~> 0.29", only: :dev, runtime: false}
    ]
  end

  defp elixirc_paths(:test), do: ["lib", "test/support"]
  defp elixirc_paths(_), do: ["lib"]
end

15.2 Mix Tasks

mix compile        # Compile project
mix test           # Run tests
mix format         # Format code
mix deps.get       # Get dependencies
mix deps.update    # Update dependencies
mix clean          # Remove build artifacts

mix test test/my_test.exs

mix test --cover

mix docs

iex -S mix

MIX_ENV=prod mix compile

15.3 Custom Mix Tasks

defmodule Mix.Tasks.Hello do
  use Mix.Task

  @shortdoc "Says hello"
  def run(_args) do
    IO.puts("Hello from custom task!")
  end
end

Section 16: Documentation with ExDoc

16.1 Module Documentation

defmodule Calculator do
  @moduledoc """
  Provides basic arithmetic operations.

  ## Examples

      iex> Calculator.add(2, 3)
      5

      iex> Calculator.divide(10, 2)
      {:ok, 5.0}
  """

  @doc """
  Adds two numbers.

  ## Parameters

    - `a`: First number
    - `b`: Second number

  ## Examples

      iex> Calculator.add(5, 3)
      8

      iex> Calculator.add(0, 0)
      0
  """
  def add(a, b), do: a + b

  @doc """
  Divides two numbers.

  Returns `{:ok, result}` on success or `{:error, reason}` on failure.

  ## Examples

      iex> Calculator.divide(10, 2)
      {:ok, 5.0}

      iex> Calculator.divide(10, 0)
      {:error, :division_by_zero}
  """
  def divide(_a, 0), do: {:error, :division_by_zero}
  def divide(a, b), do: {:ok, a / b}
end

16.2 Doctests

defmodule MathTest do
  use ExUnit.Case
  doctest Calculator  # Runs all examples in Calculator docs
end

Exercises

Level 1: Basic Operations

  1. Implement Math.gcd/2 (greatest common divisor) using recursion
  2. Create StringUtils.word_count/1 to count words in a sentence
  3. Build ListUtils.unique/1 to remove duplicates
  4. Write NumberUtils.is_prime?/1 to check if number is prime
  5. Implement StringUtils.reverse_words/1 to reverse words in a sentence

Level 2: Data Structures

  1. Implement a Stack module with push, pop, and peek
  2. Create a Queue module with enqueue, dequeue
  3. Build a Set module using maps

Level 3: Real Applications

  1. Contact book with search and filtering
  2. Simple expense tracker with categories
  3. Text-based game with state management

Level 4: Testing

  1. Write comprehensive tests for your Stack module
  2. Create test suite for CSV parser with edge cases
  3. Test Todo List with property-based testing

Related Content

Previous Tutorials:

Next Steps:

How-To Guides:

Explanations:

Reference:


Next Steps

Congratulations! You’ve mastered Elixir fundamentals (0-60% coverage).

Continue your journey:

  1. Intermediate Tutorial - OTP deep dive (60-85%)

    • GenServer patterns
    • Supervision trees
    • Phoenix framework
    • Ecto database patterns
  2. Cookbook - Solutions to common problems

  3. Build Projects - Apply your knowledge to real applications

You’re now ready to:

  • Build complete Elixir applications
  • Write idiomatic, tested code
  • Understand real-world Elixir codebases
  • Contribute to Elixir projects
  • Learn Phoenix and OTP patterns

Keep practicing and building! The Intermediate tutorial awaits when you’re ready for production-grade patterns.

Last updated