6 min read
On this page

Guards and the Pin Operator

Pattern matching gets you a long way, but it can only check shapes and exact values. Guards extend it: they let you add boolean conditions to a pattern. The pin operator goes the other direction: it lets you match against the value of an existing variable rather than rebinding it.

These two features are small but load-bearing. Almost every non-trivial function clause in real Elixir code uses one or the other.

Guard Clauses

A guard is a when clause attached to a pattern. The pattern matches first; then the guard expression evaluates. Both must succeed for the clause to fire.

def classify(n) when n < 0, do: :negative
def classify(0), do: :zero
def classify(n) when n > 0, do: :positive

The when clause can use a restricted subset of Elixir expressions. Not every function is allowed — guards have to be safe to evaluate without side effects, fast, and unable to crash the matcher. The runtime enforces this with a hard list of allowed operations.

What's Allowed in Guards

The full list is in the Elixir docs, but the common ones:

Comparisons: ==, !=, ===, !==, <, >, <=, >=

Boolean operators: and, or, not, &&, ||, !

Arithmetic: +, -, *, /, div, rem, abs, round, trunc

Type checks: is_atom/1, is_binary/1, is_bitstring/1, is_boolean/1, is_float/1, is_function/1, is_function/2, is_integer/1, is_list/1, is_map/1, is_map_key/2, is_nil/1, is_number/1, is_pid/1, is_port/1, is_reference/1, is_struct/1, is_struct/2, is_tuple/1, is_exception/1

Term inspection: tuple_size/1, length/1, map_size/1, byte_size/1, bit_size/1, hd/1, tl/1, elem/2

Range membership: n in 1..100

Map access: map.key (raises if missing — be careful)

What's not allowed: anything user-defined that isn't a defguard (next section), most string functions, regex, IO, anything stateful. If you put a regular function in a guard, you'll get a compile error.

# valid
def small?(n) when is_integer(n) and n >= 0 and n < 100, do: true

# valid
def has_email?(%{email: email}) when is_binary(email) and byte_size(email) > 0, do: true

# invalid — String.contains? is not allowed in guards
def has_at?(s) when String.contains?(s, "@"), do: true

Multiple Guards on One Clause

You can chain conditions with and/or (or the older ,/when separators, which mean and/or respectively but read worse):

def adult_admin?(%User{age: age, role: role})
    when is_integer(age) and age >= 18 and role == :admin, do: true
def adult_admin?(_), do: false

If the guard is long, format it across lines:

def valid?(%Order{status: status, total: total})
    when status in [:pending, :paid] and
         is_integer(total) and
         total > 0 do
  true
end

Guards with Multiple Function Heads

The combination of pattern matching and guards lets you write what would be a long if/elif/else chain as a series of clean clauses.

defmodule Temperature do
  def describe(c) when c < 0, do: :freezing
  def describe(c) when c < 15, do: :cold
  def describe(c) when c < 25, do: :mild
  def describe(c) when c < 35, do: :warm
  def describe(_), do: :hot
end

Each clause reads independently. Adding a new band is one new line. Compare to the same logic as a cond:

# works, but harder to extend
def describe(c) do
  cond do
    c < 0 -> :freezing
    c < 15 -> :cold
    c < 25 -> :mild
    c < 35 -> :warm
    true -> :hot
  end
end

Both are valid. The clause-based form is preferred when the logic is conceptually a dispatch on input shape or value range.

When Guards Fall Through

If no clause matches, you get a FunctionClauseError. This is loud by design — the alternative would be silent fall-through to a default that often masks bugs.

defmodule MathOps do
  def reciprocal(n) when is_number(n) and n != 0, do: 1 / n
end
iex> MathOps.reciprocal(0)
** (FunctionClauseError) no function clause matching in MathOps.reciprocal/1

If you want a default branch, write one. If you don't, callers get a clear error pointing at the function name and the value that failed to match.

The Pin Operator

By default, a variable in a pattern binds. Sometimes you want the opposite — match against the value the variable already holds. That's the pin operator, ^.

iex> expected = 42

iex> ^expected = 42
42

iex> ^expected = 7
** (MatchError) no match of right hand side value: 7

Without the pin, expected = 7 would just rebind expected to 7. With the pin, it asserts the right side equals the existing value of expected.

In case and function clauses, this is occasionally useful:

def find_user(id, users) do
  Enum.find(users, fn
    %User{id: ^id} -> true
    _ -> false
  end)
end

Here ^id matches against the function argument id. Without the pin, %User{id: id} would bind a new local id, shadowing the outer one and matching every user.

Pin in Map and Struct Patterns

The pin works inside any compound pattern:

def from_company?(%User{email: email}, ^company_domain) do
  String.ends_with?(email, "@#{company_domain}")
end

Wait — that's not quite right. Pins go on values you're matching against, not on bound function arguments. In a function head, the argument names are already in pattern position. The pin is for cases where you want to use an existing variable's value as a literal in another pattern.

A cleaner example:

def update_if_matches(state, expected_version, new_value) do
  case state do
    %{version: ^expected_version, value: _} ->
      {:ok, %{state | value: new_value, version: expected_version + 1}}

    _ ->
      {:error, :stale_version}
  end
end

The first clause only matches when the state's version field equals the expected_version argument. Without the pin, it would match any version and rebind the variable.

Pin in Anonymous Function Clauses

Anonymous functions can have multiple clauses with patterns and guards too:

target_id = "user-42"

action_for_target = fn
  %{id: ^target_id, action: action} -> {:found, action}
  _ -> :not_found
end

Without the pin, the first clause matches any map with :id and :action keys, then binds whatever id was to a local variable.

Custom Guards with defguard

You can't put arbitrary functions in guards, but you can define new guard expressions with defguard. These are macros that expand at compile time into allowed guard expressions.

defmodule Numbers do
  defguard is_positive(n) when is_integer(n) and n > 0
  defguard is_in_range(n, min, max) when is_integer(n) and n >= min and n <= max

  def factorial(n) when is_positive(n) do
    Enum.reduce(1..n, 1, &*/2)
  end
end

defguard is for reuse. If you find yourself writing is_integer(n) and n > 0 in eight different clauses, factor it out. The guard is inlined at the call site, so there's no runtime cost.

You can also have defguardp for private guards (only usable within the defining module).

A real-world example from a library you'd write:

defmodule Auth do
  @admin_roles [:admin, :superadmin, :owner]

  defguard is_admin(user) when is_map(user) and is_map_key(user, :role) and user.role in @admin_roles

  def can_delete?(user) when is_admin(user), do: true
  def can_delete?(_), do: false
end

Guards in case and with

Guards aren't just for function clauses. They work in case, cond (sort of, via true ->), and even receive:

case Repo.get(User, id) do
  %User{role: :admin} = user -> {:ok, user}
  %User{age: age} = user when age >= 18 -> {:ok, user}
  %User{} -> {:error, :forbidden}
  nil -> {:error, :not_found}
end

The same patterns and guards you use in function heads work in case arms.

Common Pitfalls

Putting a regular function in a guard and getting a compile error. Only the guard-allowed function set works. Convert to defguard if you need a custom predicate, or move the check out of the guard into the body.

Guards on the wrong clause. def foo(x) when is_integer(x), do: x * 2 versus def foo(x), do: when is_integer(x), x * 2. The first is correct; the second is a syntax error. Guards attach to the function head before do: or do.

Forgetting that nil and false are falsy in guards. when user.admin will fail if :admin is missing (raises KeyError) — guards do not silently coerce. Use when is_map_key(user, :admin) and user.admin or check explicitly.

Pin operator overuse. If you find yourself pinning every variable, you probably want == in a guard, which reads more clearly. Pins shine when you're matching deep inside a pattern.

Guards that crash the matcher. Some operations raise even in guards (e.g., map.missing_key raises KeyError). The runtime catches these and treats the clause as not matching, but it still costs time. Prefer is_map_key/2 for safer dispatch.

Multiple guards meaning AND vs. OR. Inside a single when, use and/or. The legacy syntax when a, b (comma) means AND, and when a when b (multiple whens) means OR. The comma form is fine; the multiple-when form is rare and confuses readers. Stick to and/or.

Key Takeaways

  • Guards add boolean checks to patterns via when, but only allowed expressions work.
  • The pin operator ^ matches against an existing variable's value rather than rebinding.
  • Multiple function heads with guards replace if/elif/else chains and are easier to read and extend.
  • defguard defines reusable guard expressions; they expand at compile time.
  • Guards in case and with arms work the same way as in function heads.
  • Failed guards in clauses fall through to the next clause; failure across all clauses raises FunctionClauseError.
  • Pin sparingly. If a pattern is mostly pins, a comparison in a guard usually reads better.