Functions and Arity
If you come to Elixir from Python or JavaScript, the first thing that will trip you up is that foo/2 and foo/3 are different functions. Not overloads. Not variants. Different functions. The arity is part of the identity, and once you internalize that, a lot of Elixir's design starts to make sense.
Named Functions: def and defp
Inside a module, you define functions with def for public and defp for private. There is no other access modifier. Either the rest of the world can call your function or it cannot.
defmodule Account do
def withdraw(account, amount) do
new_balance = calculate_balance(account.balance, amount)
%{account | balance: new_balance}
end
defp calculate_balance(current, amount) when amount > 0 do
current - amount
end
end
Account.withdraw/2 is callable from anywhere. calculate_balance/2 is only reachable from within the Account module. Try to call it from outside and the compiler will tell you the function is undefined or private.
A common mistake: thinking defp hides implementation details for testing. It does not. If you want to test a private function, either make it public or test through the public surface. Most Elixir codebases lean toward the second option, and Phoenix's own source is full of public helpers that exist mostly so they can be exercised in tests.
Arity Is Part of the Function's Identity
This is the rule that surprises everyone:
defmodule Greeter do
def hello(name), do: "Hello, #{name}"
def hello(first, last), do: "Hello, #{first} #{last}"
end
Greeter.hello("Jose") # calls hello/1
Greeter.hello("Jose", "Valim") # calls hello/2
hello/1 and hello/2 are completely separate functions that happen to share a name. When you read documentation or stack traces, functions are always referred to as Module.name/arity. String.split/1, String.split/2, and String.split/3 are three distinct functions in the standard library.
This is why you often see Elixir docs with entries like Enum.reduce/2 and Enum.reduce/3 listed separately. They are.
Default Arguments with \\
You can give parameters default values using \\. The compiler generates the lower-arity versions for you.
defmodule Pricing do
def discount(price, percent \\ 10, currency \\ "USD") do
"#{price * (1 - percent / 100)} #{currency}"
end
end
Pricing.discount(100) # uses percent=10, currency="USD"
Pricing.discount(100, 20) # uses currency="USD"
Pricing.discount(100, 20, "EUR")
Under the hood, this generates discount/1, discount/2, and discount/3. If you also write a multi-clause function with defaults, you need a function head with no body to anchor the defaults:
def fetch(url, opts \\ [])
def fetch(url, opts) when is_binary(url) do
HTTPoison.get(url, [], opts)
end
def fetch(%URI{} = uri, opts) do
uri |> to_string() |> fetch(opts)
end
Forget the head and the compiler will warn you that defaults are being defined multiple times.
Multi-Clause Functions
Pattern matching extends to function definitions. You write multiple clauses and Elixir picks the first one whose patterns and guards match.
defmodule Parser do
def parse({:ok, body}), do: {:ok, decode(body)}
def parse({:error, %{status: 404}}), do: {:error, :not_found}
def parse({:error, %{status: status}}) when status >= 500, do: {:error, :server_error}
def parse({:error, _}), do: {:error, :unknown}
end
This is how most Elixir code branches. Instead of an if ladder or a case block, you let the function definition itself express the branches. Bleacher Report's article ingestion pipeline famously uses this pattern heavily — each clause handles a specific shape of incoming data, and adding a new shape means adding a new clause, not modifying existing logic.
Order matters. Elixir tries clauses top to bottom and takes the first match. Put a wildcard clause early and you will shadow everything below it.
Anonymous Functions with fn
Anonymous functions live in expression position and use fn ... end:
add = fn a, b -> a + b end
add.(2, 3) # 5
Note the dot. add.(2, 3) calls the anonymous function bound to add. Without the dot, Elixir would look for a local function called add/2. This trips up newcomers constantly. The dot is the language saying "this is a value being invoked, not a named function."
Anonymous functions can also have multiple clauses:
classify = fn
n when n < 0 -> :negative
0 -> :zero
n when n > 0 -> :positive
end
classify.(-5) # :negative
classify.(0) # :zero
All clauses must have the same arity. You cannot mix a one-arg clause with a two-arg clause inside a single fn.
The & Capture Operator
& is the shorthand most newcomers love to hate and then love. It does two things:
Capture an existing function:
upcase = &String.upcase/1
upcase.("hello") # "HELLO"
["alice", "bob"] |> Enum.map(&String.upcase/1)
Build a quick anonymous function:
add_one = &(&1 + 1)
add_one.(5) # 6
Enum.map([1, 2, 3], &(&1 * 2)) # [2, 4, 6]
&1, &2, etc. refer to positional arguments. &(&1 + &2) is equivalent to fn a, b -> a + b end.
When you should reach for &: simple expressions or capturing a function that already exists. When you should not: anything with multiple statements, pattern matching, or where readability suffers. Enum.map(users, &(&1.name |> String.trim() |> String.downcase())) is borderline. Enum.map(users, fn user -> user.name |> String.trim() |> String.downcase() end) is clearer.
Capturing Function References
& is the only way to get a reference to a named function. Functions are not values you can pass around without it.
# This does not work — Elixir thinks you are calling String.upcase with no args.
Enum.map(["a", "b"], String.upcase)
# This works.
Enum.map(["a", "b"], &String.upcase/1)
You can also partially apply by mixing literals with &n:
greet = &"Hello, #{&1}!"
greet.("Discord") # "Hello, Discord!"
prefix_with = &("[#{&1}] #{&2}")
prefix_with.("INFO", "user signed in") # "[INFO] user signed in"
Guards in Function Definitions
Guards extend pattern matching with predicates. They start with when and use a restricted subset of expressions — is_integer/1, is_list/1, comparison operators, in, basic arithmetic, and a handful of others. Function calls and side effects are not allowed.
defmodule Shipping do
def cost(weight) when is_number(weight) and weight > 0 and weight <= 1, do: 5.00
def cost(weight) when is_number(weight) and weight <= 5, do: 10.00
def cost(weight) when is_number(weight) and weight <= 20, do: 25.00
def cost(weight) when is_number(weight), do: {:error, :too_heavy}
def cost(_), do: {:error, :invalid_weight}
end
Guards are checked after the pattern matches but before the body runs. They are how you express "this clause applies when the value has this shape and also satisfies this property." A clause whose pattern matches but whose guard fails is skipped, and the next clause is tried.
The restriction to side-effect-free expressions is intentional — guards must be cheap and predictable enough that the runtime can evaluate them millions of times without surprise. If you need a real predicate, do the match and check inside the body.
Function Naming Conventions
A few conventions hold across nearly all Elixir code:
Functions that return a boolean end with ?: Enum.empty?/1, String.contains?/2. The question mark is part of the name, not punctuation.
Functions that raise on failure end with !: File.read!/1, Map.fetch!/2. The non-bang version typically returns {:ok, value} or {:error, reason}. Most Elixir libraries provide both — use the bang version when failure should crash and the regular version when you want to handle errors.
Functions that change something internal sometimes start with do_: do_request/3 is a private helper for request/3. Not a hard rule, but common.
Common Pitfalls
Forgetting the dot when calling anonymous functions. f(x) calls a named function f/1. f.(x) calls the anonymous function bound to f. The compiler error message — "undefined function f/1" — is technically correct but rarely the first place beginners look.
Assuming def foo(x) and def foo(x, y) are overloads. They share a name in source but are unrelated functions at the BEAM level. You can have completely different documentation, specs, and implementations for each arity, and most editors will treat them as separate symbols.
Putting the catch-all clause first. Multi-clause matching is top-down. def handle(_), do: :default followed by more specific clauses means the more specific clauses are unreachable, and you get a compiler warning about it.
Default arguments without a function head. If you have multiple clauses and one of them has \\, you need a header. Otherwise the compiler will reject the file with an error about multiple default value definitions.
Trying to use defp for "private to a single function." There is no nested-function privacy in Elixir. defp is module-private. If you need a helper used only by one function, it still lives at the module level — typically with a name like do_thing/2 if its public counterpart is thing/1.
Key Takeaways
defis public,defpis private to the module. There are no other levels.- Arity is part of the function's identity.
foo/2andfoo/3are unrelated. - Multi-clause functions use pattern matching at the definition site to branch.
- Anonymous functions are values you call with the dot syntax:
f.(args). &captures named functions and writes terse anonymous ones — use it when it improves readability, not by default.- Default arguments use
\\and require a function head when combined with multiple clauses.