Elixir.guide
Modern Elixir / Phoenix Edition (2026)

Learn Modern Elixir for Python and Javascript Developers

If you are tired of debugging `undefined is not a function` in JS, fighting the Global Interpreter Lock (GIL) in Python, or managing a chaotic web of sprawling Microservices to handle background jobs and WebSockets... welcome to Elixir.

Created by José Valim, Elixir combines the gorgeous developer ergonomics of Ruby with the legendary industrial strength of the Erlang BEAM Virtual Machine. It gives you 99.9999999% uptime, true effortless concurrency, and a functional paradigm that makes your code predictable. Coupled with the Phoenix Framework and LiveView, you can build rich, real-time web applications without writing custom JavaScript state management.

2. The Rosetta Stone: Types Grid

Like Python and JS, Elixir is dynamically but strongly typed. The compiler won't let you add an integer to a string.

Concept Elixir Python 3.12+ JS (ES2026)
Variable Binding x = 42 x = 42 const x = 42;
Atom (Symbol) status = :ok
# Used everywhere for signaling
N/A (Strings used) Symbol("ok")
String name = "Elixir" name = "Python" const name = "JS";
Null / None val = nil val = None const val = null;
Tuple res = {:ok, 200}
# Fixed length, fast read
res = ("ok", 200) const res = ["ok", 200];
List nums = [1, 2, 3]
# Singly Linked List, fast prepend
nums = [1, 2, 3] const nums = [1, 2, 3];
Map (Dictionary) user = %{id: 1, name: "A"} user = {"id": 1, "name": "A"} const user = {id: 1, name: "A"};
Keyword List opts = [timeout: 5000]
# Just a list of tuples under the hood
**kwargs {timeout: 5000}

3. Immutability & Variables

In Python and JS, variables are often references to objects in memory that can be mutated from anywhere, leading to nasty bugs. In Elixir, all data is immutable. When you modify a list or map, Elixir returns a new copy (sharing memory behind the scenes for efficiency).

Python 3 (Mutable)
def add_user(users):
    users.append("Charlie") # MUTATES!
    return users

my_users = ["Alice", "Bob"]
add_user(my_users)

# The original list is permanently changed
print(my_users) 
# => ['Alice', 'Bob', 'Charlie']
JS (Mutable)
function addUser(users) {
  users.push("Charlie"); // MUTATES!
  return users;
}

const myUsers = ["Alice", "Bob"];
addUser(myUsers);

// myUsers is changed despite 'const'
console.log(myUsers); 
// => ['Alice', 'Bob', 'Charlie']
Elixir (Immutable)
def add_user(users) do
  # Lists are Linked Lists. Prepends are O(1)
  # This returns a brand new list!
  ["Charlie" | users]
end

my_users = ["Alice", "Bob"]
new_users = add_user(my_users)

# The original list is completely safe
IO.inspect(my_users) 
# => ["Alice", "Bob"]

4. Pattern Matching (The "If" Killer)

In Elixir, the = sign is not an assignment operator; it is a Match Operator. It works like algebra. `x = 1` sets x to 1. But `1 = x` is perfectly valid Elixir—it asserts that x is 1. If it's not, the program crashes. This eliminates huge if/else blocks.

JS/Python (If/Else ladders)
function handleResponse(response) {
  // Destructuring exists, but it doesn't control flow
  if (response.status === 200 && response.body.user) {
    return `Welcome ${response.body.user.name}`;
  } else if (response.status === 404) {
    return "Not found!";
  } else {
    return "Unknown error";
  }
}
Elixir (Function Clauses & Pattern Matching)
# Elixir allows multiple function definitions. 
# It runs the first one where the arguments perfectly MATCH the pattern!

def handle_response(%{status: 200, body: %{user: %{name: name}}}) do
  # It asserts status is 200, deeply extracts `name`, and assigns it.
  "Welcome #{name}"
end

def handle_response(%{status: 404}) do
  "Not found!"
end

# The catch-all (matches anything)
def handle_response(_response) do
  "Unknown error"
end

5. The Pipe Operator `|>`

If there is one reason developers fall in love with Elixir, it's the Pipe Operator. It takes the output of the expression on its left, and passes it as the first argument to the function on its right. Data flows beautifully from top to bottom.

Task: Take a CSV string, split it, convert to integers, keep only even numbers, and sum them up.

Python (List Comprehensions)
data = "1,2,3,4,5,6"

# Pythonic, but reads inside-out 
# and gets messy if logic is complex.
result = sum([
    int(x) for x in data.split(",") 
    if int(x) % 2 == 0
])
JS (Method Chaining)
const data = "1,2,3,4,5,6";

// Nice, but only works because map/filter 
// are attached to the Array class object.
const result = data
  .split(",")
  .map(x => parseInt(x))
  .filter(x => x % 2 === 0)
  .reduce((acc, curr) => acc + curr, 0);
Elixir (The Pipe)
data = "1,2,3,4,5,6"

# Pure functions, no class objects required.
# Reads like a data assembly line.
result = 
  data
  |> String.split(",")
  |> Enum.map(&String.to_integer/1)
  |> Enum.filter(fn x -> rem(x, 2) == 0 end)
  |> Enum.sum()

6. Guess the Number Game

Because Elixir is immutable, there are no while loops. We use Recursion. The Erlang VM has Tail Call Optimization, meaning a recursive function call at the end of a block will never cause a stack overflow.

Python 3 (While Loop & State)
import random

def play_game():
    secret = random.randint(1, 100)
    print("Guess a number!")
    
    while True:
        try:
            guess = int(input("> "))
            if guess < secret:
                print("Higher!")
            elif guess > secret:
                print("Lower!")
            else:
                print("You win!")
                break
        except ValueError:
            print("Invalid input!")

play_game()
Elixir (Recursion & Pattern Matching)
defmodule Game do
  def play do
    secret = :rand.uniform(100)
    IO.puts("Guess a number!")
    loop(secret)
  end

  def loop(secret) do
    # Get input, pipe to trim, pipe to integer parse
    input = IO.gets("> ") |> String.trim() |> Integer.parse()

    # Integer.parse returns {integer, remainder} or :error
    case input do
      {guess, _} when guess < secret ->
        IO.puts("Higher!")
        loop(secret) # Tail recursion!
        
      {guess, _} when guess > secret ->
        IO.puts("Lower!")
        loop(secret)
        
      # The Pin operator (^) matches against the EXISTING variable
      {^secret, _} ->
        IO.puts("You win!")
        
      :error ->
        IO.puts("Invalid input!")
        loop(secret)
    end
  end
end

Game.play()

7. File I/O & JSON Parsing

Reading files and parsing JSON is incredibly common. Elixir uses the Jason library (almost universally included).

Node.js (Imperative)
const fs = require('fs');

function extractEmails() {
  try {
    const rawData = fs.readFileSync('users.json', 'utf8');
    const users = JSON.parse(rawData);
    
    // Map over the array to get emails
    const emails = users.map(user => user.email);
    console.log(emails);
  } catch (error) {
    console.error("Failed to read file", error);
  }
}
Elixir (Declarative Pipes)
defmodule UserProcessor do
  def extract_emails do
    # Notice the ! at the end of the functions.
    # In Elixir, a bang (!) means "Crash if there is an error".
    # Without the bang, it returns {:ok, data} or {:error, reason}.
    
    "users.json"
    |> File.read!()
    |> Jason.decode!()
    |> Enum.map(fn user -> user["email"] end)
    |> IO.inspect()
  end
end

8. Error Handling (The with block)

Exceptions (try/catch) are meant for exceptional circumstances (like a DB going down). For expected failures (like a user typing bad input, or a file missing), Elixir returns tuples. To prevent nested case statements, Elixir provides the brilliant with block.

Python (Nested Try/Except & Checks)
def process_purchase(user_id, item_id):
    user = fetch_user(user_id)
    if not user:
        return "User not found"
        
    item = fetch_item(item_id)
    if not item:
        return "Item not found"
        
    if user.balance < item.price:
        return "Insufficient funds"
        
    # Proceed to charge
    return charge_user(user, item)
Elixir (The `with` statement)
def process_purchase(user_id, item_id) do
  # `with` executes steps in order. 
  # If any step DOES NOT match the left side of <-, 
  # the chain stops and returns the mismatched value.
  
  with {:ok, user} <- fetch_user(user_id),
       {:ok, item} <- fetch_item(item_id),
       :ok <- check_balance(user, item) do
       
    # If all matches succeed, do this:
    charge_user(user, item)
    
  else
    # Pattern match on the failure points!
    {:error, :user_not_found} -> "User not found"
    {:error, :item_not_found} -> "Item not found"
    {:error, :insufficient_funds} -> "Insufficient funds"
  end
end

9. Tasks (The Async/Await Killer)

In Javascript, doing 5 API calls in parallel requires Promise.all(). In Python, it requires asyncio.gather() and managing an event loop. In Elixir, concurrency is not a library—it is baked into the VM. You just spawn a Task.

JS (Promise.all)
const urls = ['/api/1', '/api/2', '/api/3'];

async function fetchAll() {
  // Map strings to an array of unresolved Promises
  const requests = urls.map(url => fetch(url));
  
  // Wait for all to resolve in parallel
  const responses = await Promise.all(requests);
  return responses;
}
Elixir (Task.async)
urls = ["/api/1", "/api/2", "/api/3"]

def fetch_all(urls) do
  urls
  # Spawns a lightweight process for each URL
  |> Enum.map(fn url -> Task.async(fn -> fetch(url) end) end)
  # Awaits the results (blocks the calling process, but not the whole VM)
  |> Enum.map(fn task -> Task.await(task) end)
  
  # Or use the built-in shorthand:
  # Task.async_stream(urls, &fetch/1) |> Enum.to_list()
end

10. Concurrency Deep Dive: The Ring Benchmark

The true power of the BEAM VM is the Actor Model. In Python, spawning 100,000 OS threads will crash your laptop. Node.js is single-threaded. Elixir "processes" are incredibly lightweight (starting at ~2KB). Elixir can spawn millions of processes on a standard laptop, spreading them perfectly across all available CPU cores. Processes communicate exclusively via Message Passing.

Elixir (Bare Metal Spawn & Receive)
defmodule Ring do
  @doc "Spawns N processes in a circle, and passes a message M times."
  def start(n, m) do
    # Build the ring backwards. self() is the main process PID.
    first_pid = create_ring(n, self())
    
    # Send the initial message tuple to the first process
    send(first_pid, {:message, m})
    
    # Block and wait for the completion signal back
    receive do
      :done -> IO.puts("Finished passing message #{m} times around #{n} nodes!")
    end
  end

  defp create_ring(1, next_pid), do: spawn(fn -> process_node(next_pid) end)
  defp create_ring(n, next_pid) do
    current_pid = spawn(fn -> process_node(next_pid) end)
    create_ring(n - 1, current_pid)
  end

  # Every node sits in this recursive loop, waiting for a message
  defp process_node(next_pid) do
    receive do
      # Pattern match: If 0 trips left, we are done. Forward :done.
      {:message, 0} -> send(next_pid, :done)
        
      # Otherwise, decrement and forward the message, then loop again.
      {:message, trips_left} ->
        send(next_pid, {:message, trips_left - 1})
        process_node(next_pid) # Tail recursion keeps memory flat!
        
      # Forward the :done token and exit.
      :done -> send(next_pid, :done)
    end
  end
end

# Spawns 100,000 processes instantly:
# Ring.start(100_000, 10)

11. State Management: OOP Classes vs GenServer

Because Elixir variables are immutable, how do you hold state (like an in-memory counter or a cache)? You use a process! A GenServer (Generic Server) is the standard OTP behavior that runs a continuous loop holding state, accepting synchronous calls and asynchronous casts.

Python 3 (Class State)
# Thread-unsafe in multi-threaded environments 
# without manual lock management!

class Counter:
    def __init__(self, initial=0):
        self.count = initial
        
    def get_count(self):
        return self.count
        
    def increment(self):
        self.count += 1

# Usage:
counter = Counter(10)
counter.increment()
print(counter.get_count())
Elixir (GenServer State)
# Runs in its own thread. Mailbox inherently queues messages,
# so it is 100% thread-safe without manual locks.

defmodule Counter do
  use GenServer

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

  def get_count do
    GenServer.call(:my_counter, :get_count) # Synchronous
  end

  def increment do
    GenServer.cast(:my_counter, :increment) # Asynchronous
  end

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

  @impl true
  def handle_call(:get_count, _from, state) do
    # {:reply, response, new_state}
    {:reply, state, state}
  end

  @impl true
  def handle_cast(:increment, state) do
    # {:noreply, new_state}
    {:noreply, state + 1}
  end
end

# Usage:
# Counter.start_link(10)
# Counter.increment()
# IO.puts(Counter.get_count())

12. The Phoenix Framework

Phoenix is Elixir's premier web framework (comparable to Django, Express, or Ruby on Rails, but capable of millions of simultaneous connections). It compiles your routing and HTML templates using Macros, meaning your views evaluate at microsecond speeds.

Sub-millisecond Responses

Phoenix pages frequently render in 50-100 microseconds. Not milliseconds. Microseconds.

Ecto (Database Wrapper)

Not exactly an ORM. Ecto uses Changesets to validate data without hiding SQL logic. It enforces a strict boundary between database data and application logic.

Channels

Natively handle millions of WebSockets on a single machine. No Redis required. A broadcast on Node A reaches connected clients on Node B instantly.

13. Real-time: React SPA vs Phoenix LiveView

The industry spent the last decade shifting state to the browser (React/Vue), requiring APIs, state synchronization, and complex JS tooling. LiveView brings state back to the server. When a user clicks a button, an event is sent over a WebSocket. The server calculates the state change, computes the exact minimal DOM diff, and pushes just the diff back. You write zero custom Javascript.

React + NodeJS (Double State & API Mapping)
// Frontend (React Component)
function Counter() {
  const [count, setCount] = useState(0);

  const increment = async () => {
    // 1. Send HTTP request
    await fetch('/api/inc', {method: 'POST'});
    // 2. Update local state
    setCount(count + 1);
  };

  return (
    <div>
      <h1>{count}</h1>
      <button onClick={increment}>+</button>
    </div>
  );
}

// Backend (Express API)
app.post('/api/inc', async (req, res) => {
  await db.query("UPDATE counter SET val = val + 1");
  res.send({status: 'ok'});
});
Phoenix LiveView (Single Source of Truth)
defmodule MyAppWeb.CounterLive do
  use MyAppWeb, :live_view

  # 1. State lives here (in a lightweight process)
  def mount(_params, _session, socket) do
    {:ok, assign(socket, count: 0)}
  end

  # 2. Events triggered from HTML hit this function natively via Websocket
  def handle_event("inc", _params, socket) do
    # Server computes the DOM diff and pushes it to browser!
    {:noreply, assign(socket, count: socket.assigns.count + 1)}
  end

  # 3. HTML Template (HEEx)
  def render(assigns) do
    ~H"""
    <div>
      <h1><%= @count %></h1>
      <!-- phx-click wires directly to handle_event -->
      <button phx-click="inc">+</button>
    </div>
    """
  end
end

14. Testing: PyTest/Jest vs ExUnit

Elixir ships with ExUnit out of the box. No need to install Jest, Mocha, or Pytest. ExUnit runs asynchronously by default, utilizing all your CPU cores, meaning test suites finish in seconds, not minutes.

Python (PyTest)
import pytest
from my_math import MathCalc

def test_addition():
    calc = MathCalc()
    assert calc.add(2, 3) == 5
    
def test_division_by_zero():
    calc = MathCalc()
    with pytest.raises(ZeroDivisionError):
        calc.divide(10, 0)
Elixir (ExUnit)
defmodule MathCalcTest do
  use ExUnit.Case, async: true # Tests run in parallel!
  
  test "addition" do
    # Just use 'assert'. ExUnit uses Macros to give 
    # beautiful colored diffs on failure.
    assert MathCalc.add(2, 3) == 5
  end
  
  test "division by zero" do
    assert_raise ArithmeticError, fn ->
      MathCalc.divide(10, 0)
    end
  end
end

15. Tips, Tricks & Beginner Gotchas

Transitioning to functional programming takes a mindset shift. Here are the common stumbling blocks.

mut

Gotcha: State Rebinding inside loops

You cannot alter a variable from outside a block. Doing `sum = 0; Enum.each(list, fn x -> sum = sum + x end)` will not work. The `sum` inside the function is a different scoped variable. You must use `Enum.reduce/3` to carry state through an iteration.

_

The Underscore (Ignore)

Elixir will warn you if you define a variable but don't use it. If you are pattern matching a tuple like {:ok, value} but only care that it was :ok, use an underscore: {:ok, _} = function_call(). If you want to document what you are ignoring, start the variable with an underscore: _reason.

/2

Function Arity

In Elixir documentation, you will constantly see functions written like String.split/2. The /2 is the Arity—it means the function takes 2 arguments. In Elixir, split/1 and split/2 are entirely different functions!

16. Software Engineering Best Practices (Tooling)

The Elixir tooling ecosystem is renowned for its excellent developer experience. Everything is built-in.

Mix

mix is your best friend. mix new app creates a project. mix deps.get fetches dependencies. mix test runs tests. mix format perfectly styles your code. You never have to configure Webpack, Pipenv, or Prettier.

Hex & HexDocs

Hex.pm is the package manager. Unlike NPM, package documentation is standardized. When you publish a package, Elixir automatically generates gorgeous, searchable documentation and hosts it on HexDocs.pm.

IEx (Interactive Elixir)

The Elixir REPL. Run iex -S mix to start a console loaded with your entire application's code. You can interact with your database, call functions, and even connect to production servers to debug live state.

ElixirLS

The Elixir Language Server. Install the extension in VSCode for auto-completion, inline documentation on hover, and automatic Dialyzer (type checking) feedback as you type.