Skip to content

Instantly share code, notes, and snippets.

@jarsen
Last active August 28, 2026 16:15
Show Gist options
  • Select an option

  • Save jarsen/a2899b45d275f3eb382760864114c87c to your computer and use it in GitHub Desktop.

Select an option

Save jarsen/a2899b45d275f3eb382760864114c87c to your computer and use it in GitHub Desktop.
Elixir/Phoenix for Python people: what to actually learn

Elixir/Phoenix for Python people: what to actually learn

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.)


1. Data

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 see is_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.


2. Pattern matching (the big one)

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 all

Pin 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.


3. Flow control / conditionals

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 if until 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.



4. Modules

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.")
end

Functions 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")        # fallback

alias, import, require, use:

  • alias Jump.Meetings.Meeting%Meeting{} instead of %Jump.Meetings.Meeting{}. Purely compile-time.
  • import Ecto.Query — brings functions bare. from(...) instead of Ecto.Query.from(...).
  • require — makes macros available in the module.
  • use — requires the module and runs its __using__/1 callback, 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: true

Compile-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
end

5. The pipe operator |>

Python 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.


6. with — the happy-path chain

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}
end

If any step fails to match, with short-circuits and returns the unmatched value. This is the idiomatic way to chain fallible operations.


7. Syntax sugar you'll see constantly

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.name or &(&1 + &2) → creates anonymous function
  • Function reference: &String.upcase/1 → a value you pass around

8. Processes & the Actor Pattern

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:

  1. Insanely cheap. You can spin up millions. The VM creates one in microseconds and each takes only a few KB of memory.
  2. Isolated. No shared memory between processes. If one crashes, it can't corrupt another's state.
  3. Stateful. A process holds state by recursing with new arguments — the loop arguments are the state.
  4. Communicating via messages. send(pid, message) drops a message in the mailbox. receive pattern-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
end

Every 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.


9. Phoenix = Plug all the way down

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")
end

The 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
end

Everything 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.


10. The Phoenix landscape

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.

Routerrouter.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
end

The schema is just data. No .save(). You build changesets for validation, then Repo.insert(changeset).


11. LiveView

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.

12. The Jump map

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.exmeeting_test.exs in the same directory.


13. What to punt to AI (do not memorize)

  • Enum/List/Map/Stream methods. map, filter, reduce cover 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! vs Application.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).
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment