The Elixir Mental Model
Switching to Elixir from a typical OO language isn't about learning new syntax. The syntax is small. The real shift is rewiring how you think about state, control flow, and failure. Most people who bounce off Elixir bounce because they tried to write Python with different keywords. The ones who stick adapt to a few core ideas, and once those click, the language gets quiet — you stop fighting it.
This piece walks through the mental model. We're not going deep on any single concept yet; later sections cover processes, OTP, and supervision in detail. The goal here is to install the right intuitions before you write much code.
Three ideas do most of the work: immutability, processes, and supervision. Everything else in the language flows from those.
Immutability Is Everywhere
In Elixir, every value is immutable. Always. There is no exception, no final keyword, no escape hatch. Once a value is bound to a name, that value never changes.
list = [1, 2, 3]
new_list = [0 | list]
list # [1, 2, 3] — unchanged
new_list # [0, 1, 2, 3]
You can rebind a name, which looks like mutation but isn't:
x = 1
x = x + 1 # rebinds x to a new value, the old 1 is unchanged
The original integer didn't mutate. You just pointed x at a different value. Anyone else holding a reference to the old value still sees 1.
Why does this matter? Two reasons. First, concurrency. With no shared mutable state, two processes literally cannot corrupt each other's data — they don't have access to each other's data. Second, reasoning. When you read a function, you don't need to wonder which of its inputs might be mutated as a side effect. The answer is none of them.
The cost is conceptual: you stop modifying things and start transforming them. A pipeline of transformations replaces a sequence of mutations.
# imperative-style pseudocode you might write in Python
# users = []
# for row in rows:
# user = parse(row)
# if user.active:
# user.normalize()
# users.append(user)
# Elixir
users =
rows
|> Enum.map(&parse/1)
|> Enum.filter(& &1.active)
|> Enum.map(&normalize/1)
The |> operator pipes a value through a series of functions. This isn't sugar — it's the way Elixir code is written. Get comfortable with it early.
Everything Is an Expression
There are no statements in Elixir. Every construct returns a value. if returns a value. case returns a value. Even try/rescue returns a value. do/end blocks return their last expression. Function definitions evaluate to the last expression in their body — there's no return keyword, because there's nothing to return from.
status =
if user.age >= 18 do
:adult
else
:minor
end
# or in a single line
status = if user.age >= 18, do: :adult, else: :minor
def classify(score) do
cond do
score >= 90 -> :excellent
score >= 70 -> :passing
true -> :failing
end
end
Because everything's an expression, you compose. You build values out of smaller values. You don't sequence statements that mutate a shared world.
Processes Are Cheap, and That Changes How You Design
A BEAM process is not a thread. It's not a coroutine in the Python sense either. It's a unit of execution with its own heap, its own garbage collector, and its own mailbox for messages. They start at around 300 bytes of overhead, and the BEAM is happy to host millions on a single node.
Two consequences:
First, you spawn processes liberally. In other languages you reuse threads from a pool because creating one is expensive. In Elixir, if you have a piece of work that should run independently — a web request, a chat session, a sensor reading — you give it its own process. WhatsApp's two-million-connections-per-server number came from doing exactly this: one process per connection.
Second, processes are how you model state. Elixir has no objects. If you need a counter, a cache, or a session, you put that state inside a process. The process owns it. Other processes ask for it by sending messages.
# the simplest stateful process: a counter
defmodule Counter do
def start, do: spawn(fn -> loop(0) end)
defp loop(count) do
receive do
{:inc, from} ->
send(from, {:ok, count + 1})
loop(count + 1)
{:get, from} ->
send(from, {:ok, count})
loop(count)
end
end
end
In real code you'd use a GenServer (covered later), which wraps this pattern with a clean API. But the mechanics are the same: state lives in a recursive loop inside a process, and the outside world interacts via messages.
The Actor Model in One Paragraph
Elixir uses what's called the actor model. An actor (process) has private state, a mailbox, and behavior. Actors communicate only through asynchronous messages. They don't share memory. They don't call each other's methods. This is the model Erlang adopted in the 1980s, and it's what languages like Akka (Scala) and Pony rebuilt later. The reason it scales is that there's no global lock, no shared heap, no synchronization primitive between actors. Each one runs at its own pace, on whichever core the scheduler picks.
If you've used Go channels, the messaging part is familiar. The difference is that BEAM processes are heavier on isolation (separate heaps, supervision) and lighter on the developer (no select ceremonies, no manual goroutine lifecycle).
The BEAM Scheduler
The BEAM runs one scheduler thread per CPU core by default. Each scheduler maintains a run queue of processes. When a process executes, it earns "reductions" — a rough proxy for function calls. After about 4,000 reductions, the scheduler preempts it and moves on to the next process in the queue.
This is preemptive multitasking at the function-call boundary. It means:
- A process running a tight loop can't starve the system. The scheduler will yank it out.
- Latency is consistent across the whole node. You don't have one slow request blocking everything.
- IO doesn't block a scheduler. The runtime offloads IO to dedicated dirty schedulers and parks the calling process.
- Garbage collection is per-process. When a small process dies, its entire heap is freed at once with no traversal — the heap just goes away.
You don't have to think about any of this when writing code. It happens underneath. But it explains why Discord can run a single Elixir node serving millions of WebSocket connections without tail latency falling off a cliff. The contrast with single-threaded event loops (Node.js) and shared-heap GC pauses (Java, Go) is sharpest under load: the BEAM's curve stays flat where others spike.
"Let It Crash" — At a High Level
The dominant pattern for error handling in Elixir is to let processes crash and have a supervisor restart them. This sounds reckless until you sit with it.
Consider the alternatives. In Python, an unexpected exception either bubbles up to a top-level handler that logs and continues (often into a corrupted state), or kills the process. In Java, you wrap everything in try/catch and end up with code where the error-handling overshadows the logic. Both approaches assume the failed code can recover where it failed.
Elixir's bet: most errors in long-running systems aren't recoverable in place. The state got into a weird shape. The downstream service hiccuped. The right move is to discard the bad state, restart fresh, and try again. A supervisor watches a process and restarts it according to a policy when it dies.
defmodule MyApp.Application do
use Application
def start(_type, _args) do
children = [
{MyApp.Cache, []},
{MyApp.Worker, []},
{Phoenix.PubSub, name: MyApp.PubSub}
]
Supervisor.start_link(children, strategy: :one_for_one)
end
end
If MyApp.Worker crashes, the supervisor restarts only that one. The cache and PubSub keep running. If the worker keeps crashing too fast (the default is 3 times in 5 seconds), the supervisor itself escalates and dies, letting its own supervisor decide what to do. This is a tree of supervisors all the way up to the application root.
You write the happy path. The supervision tree turns crashes into known events with known handlers. This is the "fault tolerance" people credit Erlang and Elixir for. It's not magic, it's structure.
Putting It Together
A typical Elixir service looks like this:
- The application starts a supervision tree.
- Long-lived state lives in GenServers under a supervisor.
- Each incoming request (HTTP, WebSocket, message) gets its own short-lived process.
- Functions transform immutable data through pipelines.
- Failures crash the relevant process, supervisors restart, and the system stays up.
You stop thinking in terms of mutating shared objects. You start thinking in terms of transforming values, isolating state into processes, and structuring failure with supervision.
A small concrete example to anchor this. Consider a "presence" feature — knowing which users are currently online in a chat room. In a typical OO/SQL stack, you'd have a presence table, write to it on connect, delete on disconnect, query it for room members, and worry about cleanup when connections drop ungracefully.
In Elixir, presence is a process per user. The process exists for the lifetime of the connection. When the WebSocket closes, the process exits and any subscribers are automatically notified through process monitoring. There's no table, no cleanup job, no orphaned rows — when the process is gone, the user is gone, by definition. Phoenix.Presence is built exactly this way and synchronizes across nodes using CRDTs, so a user connected to node A appears on node B's presence list automatically.
That's the kind of design you start writing once you've internalized processes-as-first-class. It's not faster code, it's structurally different code.
A note on what's missing from this file: we haven't covered modules, function definitions, the pipe operator's exact rules, or anything about Phoenix or LiveView. That's deliberate. Those are details, and the mental model has to come first — without it, the details look arbitrary. Once you internalize "values are immutable, processes are cheap, crashes are recovery events," the rest of the language reads as natural consequences of those choices.
Common Pitfalls
Trying to work around immutability. Newcomers reach for Process.put/2 (the process dictionary) or ETS tables to simulate mutable variables. Both are escape hatches with real uses, but if you find yourself reaching for them in week one, you're avoiding the actual model rather than learning it. Use accumulators in recursion, or pass state through pipelines.
Spawning processes for everything. Processes are for state, isolation, or independent concurrent work. They are not for organizing code or scoping helper functions. Modules organize code.
Treating "let it crash" as "ignore errors." It's the opposite. You let processes crash precisely so a supervisor can handle the failure deliberately. Without supervision, a crash is just a crash. Always set up a supervision tree before you ship anything that runs more than a few seconds.
Worrying about performance before you've written it. The BEAM is fast enough for almost any IO-bound, message-driven workload. People prematurely worry about pattern match overhead or list traversal cost and write contorted code as a result. Profile first.
Reading too much into similarities to Ruby. José Valim was a Rails core team member, so the syntax has Ruby flavor. The semantics are nothing alike. do...end is not do...end. def is not Ruby's def. Don't transfer Ruby intuitions about classes, blocks, or iteration.
Key Takeaways
- Every value is immutable. You transform values; you don't mutate them.
- Every construct is an expression that returns a value. There are no statements.
- Processes are the unit of state and concurrency. They're cheap; spawn them freely.
- The actor model means processes communicate only through messages on private mailboxes.
- The BEAM scheduler is preemptive and per-core, so no process can monopolize the system.
- "Let it crash" works because supervisors turn process death into a structured recovery event.
- Get comfortable with the pipe operator and pattern matching early. They're load-bearing.
- Don't try to map Python or Ruby idioms onto Elixir — invest in learning the model first.