The single concept that unlocks reading Elixir code
Open the public Speechwave repo alongside this lesson, or just follow the file links below. Every example comes from the real codebase, and by the end you'll be able to read the pattern matching in any file in the project.
Primary source: Elixir Getting Started: Pattern Matching (official guide, ~10 min read). Read it after this lesson to solidify.
= is not assignmentIn Ruby, = means "put this value in that box." In Elixir, = means "make the left side match the right side." This single difference changes how you read every line of code.
x = 42 → "Store 42 in x"x = 42 → "Bind x so the left side matches the right side"
When the left side is just a variable, it looks the same as assignment. The difference shows up when the left side has structure:
{:ok, result} = {:ok, 42}
# result is now 42
{:ok, result} = {:error, "not found"}
# ** (MatchError), the atom :ok doesn't match :error
if/else, Elixir often uses patterns that either match or don't.
The most common pattern in your codebase. Elixir functions return tagged tuples, {:ok, value} or {:error, reason}, and you match on the tag to handle each case.
lib/speechwave_web/live/dashboard_live.ex:47-67
case Talks.create_talk(scope, attrs) do
{:ok, talk} ->
# Success: talk is now bound to the created record
url = talk_url(talk)
# ...
{:error, changeset} ->
# Failure: changeset contains validation errors
{:noreply, assign(socket, form: to_form(changeset))}
end
result = Talks.create_talk(scope, attrs)
if result.success?
talk = result.value
# ...
else
changeset = result.error
# ...
end
In Elixir, the destructuring and the branching happen in one step. The {:ok, talk} pattern both checks that it succeeded and extracts the talk in a single expression.
In Ruby, you'd write one method with if statements inside. In Elixir, you write multiple definitions of the same function with different patterns. The runtime tries each clause top-to-bottom and uses the first one that matches.
# Clause 1: session already ended (ended_at is not nil)
def stop_session(%TalkSession{ended_at: ended_at} = session) when not is_nil(ended_at) do
{:ok, session}
end
# Clause 2: session still active (catches everything else)
def stop_session(%TalkSession{} = session) do
session
|> TalkSession.changeset(%{ended_at: DateTime.utc_now() |> DateTime.truncate(:second)})
|> Repo.update()
end
Let's unpack what's happening in that first clause:
%TalkSession{ended_at: ended_at}: match only if the argument is a TalkSession struct, and bind its ended_at field to the variable ended_at= session: also bind the entire struct to session (so you can return it)when not is_nil(ended_at): a guard clause that only matches if ended_at has a valuedef stop_session(session)
return [:ok, session] if session.ended_at.present?
session.update!(ended_at: Time.current)
[:ok, session]
end
The Elixir version encodes the condition in the function signature. You know which case you're in before you read the body.
LiveView event handlers receive params as a map. Pattern matching pulls out exactly the keys you need, right in the function signature.
lib/speechwave_web/live/dashboard_live.ex:126
def handle_event("save_rename", %{"rename" => %{"label" => label}}, socket) do
This one line does three things:
"save_rename" (a string literal in the first argument)params["rename"]["label"] and binds it to labeldef handle_event(event, params, socket)
return unless event == "save_rename"
label = params["rename"]["label"]
# ...
end
When you see %Talk{} in a function argument, it means "this function only accepts Talk structs." It's Elixir's way of doing type checking at the function boundary.
def delete_talk(%Talk{} = talk), do: Repo.delete(talk)
The %Talk{} pattern matches any Talk struct regardless of field values. The = talk binds the whole struct. If you pass a plain map or a different struct, you get a FunctionClauseError.
Your codebase uses a consistent pattern to extract the current user from a scope:
def list_talks(%Scope{user: user}) do
Repo.all(from t in Talk, where: t.user_id == ^user.id, order_by: [desc: t.inserted_at])
end
The %Scope{user: user} pattern does two things: it asserts the argument is a Scope struct, and it extracts the user field into a local variable. Every context function in Speechwave uses this pattern.
_The underscore matches anything and throws it away. You'll see it constantly.
lib/speechwave/application.ex:9
def start(_type, _args) do
This says: "I receive two arguments but don't need either." The leading underscore on a named variable (_type vs plain _) is a convention. It documents what the ignored value represents while telling the compiler you intentionally aren't using it.
lib/speechwave_web/live/dashboard_live.ex:30
def handle_params(_params, _uri, socket), do: {:noreply, socket}
Same idea: this LiveView callback is required but doesn't need the params or URI.
^By default, variables on the left side of = get rebound to whatever matches. The pin operator ^ prevents this: it says "use this variable's current value and match against it instead of rebinding."
x = 1
x = 2 # x is rebound to 2 (no pin, this is normal)
^x = 3 # ** (MatchError), 2 doesn't match 3
^x = 2 # succeeds, 2 matches 2
This is most useful inside case and function heads, where you want to match against a value you already have:
expected_slug = "my-talk"
case Talks.get_talk_by_slug(slug) do
%Talk{slug: ^expected_slug} ->
# only matches if the talk's slug equals "my-talk"
%Talk{} ->
# matches any other talk
end
Without the ^, writing %Talk{slug: expected_slug} would rebind expected_slug to whatever the slug is, matching every talk, which isn't what you want.
case/when always compares, never assigns. In Elixir, pattern matching does both. The pin operator is how you say "compare, don't assign."
You'll also see ^ in Ecto queries, where it injects an Elixir value as a SQL parameter, the same idea of "use this value, don't treat it as a new binding." That usage is covered in Lesson 5.
These quizzes use real code from Speechwave. Pick the answer that best describes what the pattern match does.
What does this pattern match extract?
def handle_event("show_qr", %{"id" => id}, socket) do
"show_qr" is a literal. It only matches that exact event. %{"id" => id} pulls the "id" key from the params map and binds its value to id. The rest of the map is ignored."show_qr" is not a variable, it's a filter. And %{"id" => id} extracts just the "id" key, not the whole map. Try again.What happens when this code runs?
{:ok, _} = Talks.delete_talk(talk)
:ok atom must match, so if delete_talk returns {:error, reason}, you get a MatchError. The _ discards the second element (the deleted struct). This is idiomatic: "I expect this to succeed; if it doesn't, something is deeply wrong."_ isn't a usable variable. It discards the value. And the :ok atom is a literal that must match. This line is asserting success. Try again.Given this function head:
def stop_session(%TalkSession{ended_at: ended_at} = session) when not is_nil(ended_at)
What kind of argument will this clause match?
%TalkSession{} struct, (2) its ended_at field is extracted and bound, (3) the guard when not is_nil(ended_at) further restricts to non-nil values. Plain maps won't match because the struct tag %TalkSession{} requires that specific struct type.%TalkSession{} restricts to that struct type (not any map), and when not is_nil(ended_at) restricts to non-nil values. Try again.In this mount function, what does the first line inside the body do?
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
user = scope.user
socket.assigns.current_scope is dot-access on a nested map/struct, similar to Ruby's hash access. The = here works like simple binding because the left side is just a bare variable. Recognizing when = is a simple bind vs. a structural match is part of reading Elixir fluently.scope = socket.assigns.current_scope has a bare variable on the left. When the left side of = is just a variable, it's a simple bind (like assignment). The dot notation is map/struct field access, not method calls or destructuring. Try again.Open lib/speechwave/talks.ex and read through it from top to bottom. For every =, case, and function head, ask yourself: "What pattern is being matched here, and what gets bound?"
You should now be able to identify:
{:ok, value})%Talk{})%{"key" => var})_ and _name)