All Elixir programs are composed of 3 things:
- Data — numbers, strings, lists, maps, functions (functions are data), etc.
- Modules — groupings of related functions and data structures
- Processes — how the code is run and where state lives
"They are all interconnected: processes run the code defined in modules that manipulate the data types."
— José Valim
Elixir is a compiled language. .ex files compile to BEAM bytecode and run on the Erlang VM — similar to how Scala, Clojure, or Kotlin compile to JVM bytecode and run on the JVM. (.exs files are scripts, evaluated at runtime.)
All data in Elixir is immutable. You can't push to a list — you create a new copy with the new element. No mutation, ever.
| Python | Elixir | Notes |
|---|---|---|
1, 1_000, 1.2 |
1, 1_000, 1.2 |
Same arithmetic, but / always returns a float |
"string", 'string' |
"string" |
'string' in Elixir is a charlist, not a String. Always use ". Elixir strings are UTF-8 binaries. |
list [1, 2, 3] |
List [1, 2, 3] |
Linked list, not array. Head/tail access is cheap; random access is O(n). |
tuple (1, 2) |
Tuple {1, 2} |
Fixed-size, contiguous in memory. Used for return tags: {:ok, value}, {:error, reason}. |
dict {"key": "val"} |
Map %{"key" => "val"} |
Key-value store. %{key: val} for atom keys, %{"key" => val} for string keys. These are different maps — Ecto uses atom keys, params come in as string keys. |
Struct %User{name: "Alice"} |
A map with a closed set of keys, tagged with a module name. Compiler-enforced — you can't misspell or add fields. Every Ecto schema is a struct. | |
**kwargs |
Keyword list [taco: "yum"] |
Sugar for [{:taco, "yum"}] — a list of 2-tuples. Used for options: Repo.insert(changeset, on_conflict: :nothing). Duplicate keys allowed. |
| Enum / bool | Atom :ok, :error |
A constant whose value is its own name. Used for tags, keys, and pattern matching. Interned globally — never create them from user input. |
lambda x, y: x + y |
fn x, y -> x + y end |
Functions are data. Pass them around, assign to variables. Call with .: fun.(1, 2). |
str |
Binary "hello" |
String is a UTF-8 binary. Concatenation is <>, not +. Interpolation: "Hello #{name}". |
Strings vs atoms (and "binaries") — Python doesn't have atoms, and this trips people constantly:
"hello"— a UTF-8 binary (this is the Erlang term; you'll seeis_binary/1,binary()type specs, and references to "binaries" everywhere in Elixir). For user data, display text, DB values.:hello— an atom. For program-internal labels: status codes, map keys, pattern-matching tags.String.to_atom("hello")exists but never do it with user input — atoms aren't garbage collected.
Structs vs Maps — mental shortcut: if someone defined a defstruct, it's a struct — closed shape, compile-checked. If you see bare %{}, it's an open map. Structs get . dot access (user.name). Maps with atom keys do too, but it crashes on missing keys; user[:name] returns nil.
Elixir does not have an assignment operator. = is a match operator.
iex> a = 4
4
iex> 4 = a # wait, what?
4 # ok...
iex> {a, b} = {3, 4}
{3, 4}
iex> {a, 4} = {3, 4}
{3, 4}
iex> {a, 3} = {3, 4}
** (MatchError) no match of right hand side value: {3, 4}The match operator tries to reconcile the left pattern with the right value. In the last example, there's no way to make a = 3 and 3 = 4 both true, so it crashes.
Ignoring values — use _:
{:ok, _result} = some_call() # document what you're ignoring
{:ok, _} = some_call() # don't care at allPin operator ^ — prevents reassignment in a match:
iex> {a, b} = {"taco", "cat"}
iex> {a, ^b} = {"pizza", "cat"} # ^b means "must equal current b"Partial pattern matching — you don't need to specify the whole pattern:
iex> animal_noises = %{cat: "meow", dog: "woof", fox: "?"}
iex> %{fox: noise} = animal_noises # only care about :fox
iex> noise
"?"This works on deeply nested structures too. You'll use this constantly in function heads, case, with, and receive.
Elixir has if, unless, case, and cond — but you won't see them as much as you'd expect.
"Elixir code tries to be declarative, not imperative. In Elixir we write lots of small functions, and a combination of guard clauses and pattern matching of parameters replaces most of the control flow seen in other languages."
— Dave Thomas (the book doesn't introduce
ifuntil chapter 12)
Idiomatic Elixir uses pattern matching in function heads instead:
def likes_tacos(true), do: "You like tacos"
def likes_tacos(_), do: "You dislike tacos"Guard clauses — constrain when a function clause matches:
def required_tacos(n) when is_integer(n), do: "You require #{n} tacos"Only a limited set of expressions are allowed in guards.
Modules are Elixir's namespaces — groups of related functions and data. No classes, no inheritance. Just functions organized by domain.
defmodule Taco do
defstruct [:type, :source, :rating] # creates %Taco{} struct
def eat_taco(taco = %Taco{}), do: IO.puts("yum")
def eat_taco(_not_taco), do: IO.puts("That was not a taco.")
endFunctions are referenced by name + arity (number of arguments): Taco.eat_taco/1. eat_taco/1 and eat_taco/2 are entirely different functions that happen to share a name.
Multiple function clauses — pattern matching on arguments. The first match wins:
def eat_taco(taco = %Taco{}), do: IO.puts("yum") # matches any Taco struct
def eat_taco(_not_taco), do: IO.puts("nope") # fallbackalias, import, require, use:
alias Jump.Meetings.Meeting—%Meeting{}instead of%Jump.Meetings.Meeting{}. Purely compile-time.import Ecto.Query— brings functions bare.from(...)instead ofEcto.Query.from(...).require— makes macros available in the module.use— requires the module and runs its__using__/1callback, which injects code. You'll see this everywhere:use Ecto.Schema,use JumpWeb, :live_view.
Module names are just atoms. Jump.Meetings is the atom :Elixir.Jump.Meetings. This means alias is literally binding a shorter atom — zero cost. Module.concat(Jump, Meetings) → Jump.Meetings.
Module attributes @ are C macros, not variables:
@duh true
def yummy?(_taco), do: @duh # compiles to: trueCompile-time only. If you reassign halfway through a module, the new value applies from that line down — a footgun. Treat them as constants.
__MODULE__ — drops in the current module atom at compile time:
defmodule Jump.Meetings do
alias __MODULE__ # same as: alias Jump.Meetings
endPython chains with .: data.strip().lower().replace(" ", "_")
Elixir functions live in modules, not on data, so naive code nests:
Enum.join(String.split(String.upcase("hello world")), "-") # 🤮The pipe operator fixes this:
"hello world" |> String.upcase() |> String.split() |> Enum.join("-")It takes the value on the left and feeds it as the first argument to the function on the right. All of Enum, Map, String, etc. are designed collection-first for this reason. And unlike method chaining, you can pipe into any function in any module.
When you'd otherwise nest 4 case statements:
with {:ok, events} <- Events.list_events(),
{:ok, result} <- MyModule.process_events(events) do
AnotherModule.handle_result(result)
else
{:error, reason} -> {:error, reason}
endIf any step fails to match, with short-circuits and returns the unmatched value. This is the idiomatic way to chain fallible operations.
| Sugar | Meaning |
|---|---|
[head | tail] |
Prepend to linked list (cheap). Pattern match to extract. |
"a" <> "b" |
String concatenation (not +) |
[1,2] ++ [3] / [1,2,3] -- [2] |
List concat / subtract |
& &1.name |
Anonymous function: fn x -> x.name end |
&String.upcase/1 |
Function reference |
~w(admin editor)a |
Word list → [:admin, :editor] (remove a for strings) |
%{user | name: "Bob"} |
Update existing key only — raises if key doesn't exist |
do: |
Single-line function: def greet(name), do: "Hello #{name}" |
~r/pattern/ |
Regex |
#{} |
String interpolation: "Hello #{name}" |
& is two things:
- Capture operator:
& &1.nameor&(&1 + &2)→ creates anonymous function - Function reference:
&String.upcase/1→ a value you pass around
Processes are the most important primitive in Elixir, but you'll almost never write one directly — GenServer, Tasks, and Agents are the abstractions you'll use. Understanding the Actor pattern underneath is what matters.
Processes are:
- Insanely cheap. You can spin up millions. The VM creates one in microseconds and each takes only a few KB of memory.
- Isolated. No shared memory between processes. If one crashes, it can't corrupt another's state.
- Stateful. A process holds state by recursing with new arguments — the loop arguments are the state.
- Communicating via messages.
send(pid, message)drops a message in the mailbox.receivepattern-matches on it. That's the only way processes interact.
Items 2–4 are, incidentally, Alan Kay's original definition of "objects" — isolated entities that hold private state and communicate via message passing. Joe Armstrong (creator of Erlang) argued that Erlang was the purest object-oriented language ever built:
"The big idea is 'messaging'... The key in making great and growable systems is much more to design how its modules communicate rather than what their internal properties and behaviors should be." — Alan Kay
Here's what a raw process looks like — a function that loops, receives messages, and recurses with new state:
defmodule Counter do
def loop(count) do
receive do
{:inc, from} ->
send(from, {:ok, count + 1})
loop(count + 1) # recurse with new state
{:get, from} ->
send(from, count)
loop(count) # recurse with same state
end
end
endEvery branch calls loop(...) again — that's how state is held. No mutation, just recursion. This is the pattern GenServer abstracts for you.
The Actor Model is the name for this pattern: independent actors, each with their own state, communicating only through asynchronous messages. This is the conceptual foundation of everything built on the BEAM — GenServers, LiveView sockets, PubSub subscribers, Oban jobs, even HTTP request handlers.
You won't write raw receive/send loops. You'll use GenServer's handle_call/handle_cast/handle_info. But the mental model is the same: actors, mailboxes, messages, state in the loop.
A Phoenix app as a process tree:
Application Supervisor
├── Endpoint Supervisor
│ ├── HTTP Listener (Bandit/Cowboy)
│ │ └── Connection process (one per request — spawned, runs plugs, dies)
│ ├── LiveView Socket processes (persistent — live until the user leaves the page)
│ └── PubSub process
├── Repo Supervisor
│ ├── DB Connection Pool (10 pooled processes)
│ └── ...
├── Oban Supervisor
│ ├── Job Queue processes
│ └── Worker processes
└── Your GenServers / Agents / Tasks
Every connection, every LiveView session, every background job — each is a process. If a LiveView crashes, the supervisor restarts just that one process. The rest of the system doesn't notice.
"Let it crash." You don't write defensive try/except everywhere. If something fails, its supervisor restarts it fresh. This is deliberate, not sloppy — the Erlang VM has been doing it in telecom switches for decades.
Phoenix is built on Plug. A Plug is a function that takes a %Plug.Conn{} struct and returns one:
def my_plug(conn, _opts) do
conn
|> assign(:current_user, lookup_user(conn))
|> put_resp_header("x-custom", "value")
endThe entire request lifecycle is just the conn being piped through a chain of plugs:
incoming request
→ Plug.Static (serve static files, maybe short-circuit)
→ Plug.Session (fetch_session, fetch_cookies)
→ Plug.CSRF (protect_from_forgery)
→ Your auth plug (assign current_user)
→ Router (pattern match on path → dispatch to controller or LiveView)
→ Controller action (build response into conn)
→ render HTML / return JSON / redirect
At the end, the conn has a resp_body, resp_headers, and status. Phoenix sends it back.
A pipeline in the router is just a named group of plugs:
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :protect_from_forgery
endEverything is the same shape: conn in, conn out. Controllers, LiveView mounts, error handlers — all plugs underneath. Once you internalize that, Phoenix stops feeling like magic.
app_web vs app
lib/jump_web/— everything HTTP: router, controllers, LiveView, templates, Plugs. The web layer.lib/jump/— business logic: schemas, contexts, integrations. Knows nothing about HTTP.
Business logic is callable from tests, scripts, Oban jobs, etc. without going through a controller.
Router — router.ex defines what URL hits what code. Pipelines group plugs; routes dispatch to controllers or LiveView.
MVC in Phoenix:
- Model → Ecto schemas + contexts
- View → HEEx templates (
.html.heex) or LiveView renders - Controller → Controllers or LiveView modules
Contexts — a module like Jump.Meetings that exposes the public API for a domain. Controllers never touch the database directly. Each context has a CONTEXT.md.
Ecto schemas — maps a DB table to an Elixir struct:
defmodule Jump.Meetings.Meeting do
use Ecto.Schema
schema "meetings" do
field :title, :string
belongs_to :user, Jump.Users.User
end
endThe schema is just data. No .save(). You build changesets for validation, then Repo.insert(changeset).
State lives on the server, diffs go over WebSockets. No JS for most interactivity.
- Socket — the persistent connection. Holds
assigns(the state). mount/3— initializes state when the page loads.handle_event/3— user actions: "clicked button", "typed in field".handle_info/2— PubSub and process messages.- PubSub — the event bus. "Meeting updated" → PubSub → all subscribed LiveViews re-render.
api/
├── lib/
│ ├── jump/ ← Business logic (contexts, schemas, integrations)
│ │ ├── meetings/ ← Domain: schemas + supporting modules
│ │ │ ├── meeting.ex
│ │ │ └── meeting_test.exs ← Tests live next to code
│ │ ├── meetings.ex ← Context: public API
│ │ └── ...
│ └── jump_web/ ← Web layer
│ ├── router.ex
│ └── live/
└── priv/repo/migrations/
Tests sit right next to the code they test: meeting.ex → meeting_test.exs in the same directory.
- Enum/List/Map/Stream methods.
map,filter,reducecover 80%. The agent looks up the weird ones. - Ecto query syntax variations. Agent translates intent.
- Enum vs Stream vs comprehensions. Agent handles translation.
- Config/runtime vs compile-time.
Application.compile_env!vsApplication.get_env. - Protocols and behaviours. Know they exist for polymorphism; let the agent implement them.
- Deployment, releases, Docker, infrastructure.
- Hex package APIs, Erlang stdlib (
:crypto,:ets,:timer).