Your country

Tools that support it use your country for local currency, number formats, units and paper size. Your choice is saved only in this browser.

Type a name or a two-letter code. Use the up and down arrow keys to move through the countries, Enter to choose one and Escape to close.

Elixir & Erlang/OTP  Module 4 – Functions and modules

Anonymous functions, closures and capture

Make and call Elixir anonymous functions with fn and a dot, give them clauses, see what a closure keeps and costs, and pass captures made with & around.

  • Beginner
  • 20 minutes
  • Examples run with elixir 1.20.4, erlang 29.1.1 and Python 3.14.8
  • By MySmartCoPilot
Free lesson, Pro answers: 20 minutes
Free: the whole lesson. With a pass: the model answers to its 2 interview questions.

What you will learn

  • Define and call anonymous functions with fn and a dot, including functions with several clauses
  • Predict what a closure returns after a name it uses is bound again
  • Measure what a closure carries and capture only the values it needs
  • Write captures of named functions and short functions with & and its numbered arguments
  • Store functions in maps, pass them to Enum and return them from other functions

Before you start

What you’ll learn · Before you start

What you will learn

  • Define and call anonymous functions with fn and a dot, including functions with several clauses
  • Predict what a closure returns after a name it uses is bound again
  • Measure what a closure carries and capture only the values it needs
  • Write captures of named functions and short functions with & and its numbered arguments
  • Store functions in maps, pass them to Enum and return them from other functions

Before you start

On this page

A checkout takes one of three discount codes on an order of 120,000 paise: none, 10 % off or a flat 15,000 off. A case on the code in every place that needs a price repeats the same branches; keep each rule as a value instead, a function in a map, and one line can pick the rule and run it. Elixir makes such values with fn and with the capture operator &, and calls them with a dot.

Below, you will make and call anonymous function: A function value without a name, made with fn … end or with &, such as fn x -> x * 2 end. A name bound to one is called with a dot, double.(5), and the value can be stored, passed and returned like any other., give one of them several clauses, see what a closure: A function that keeps the values its names had when it was made. After rate = 10, fn p -> p * rate end keeps 10 even when rate is bound to 50 later, and it carries that value into every process that runs it. keeps from the code around it and what that costs, write captures, and pass functions around as data. The programs below ran on Elixir 1.20.4 and Erlang/OTP 29.1.1.

fn makes a function, and a dot calls it

fn x -> x * 2 end is a function with no name: one argument x, an arrow, and the body. Bind it to a name, and call it by putting a dot between the name and the parentheses. The session below does that, then gets three calls wrong on purpose:

Making and calling an anonymous function in IEx Python · fn_session.py
from session import shell

# fn ... end makes a function value; a dot calls it. Each wrong call fails in its own way.
shell([
    "double = fn x -> x * 2 end",
    "double.(5)",
    "double(5)",
    "double.(1, 2)",
    "is_function(double, 1)",
    "price = 5",
    "price.(1)",
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> double = fn x -> x * 2 end
#Function<42.130099583/1 in :erl_eval.expr/6>
iex(2)> double.(5)
10
iex(3)> double(5)
error: undefined function double/1 (there is no such import)
└─ iex:3

** (CompileError) cannot compile code (errors have been logged)

iex(3)> double.(1, 2)
** (BadArityError) #Function<42.130099583/1 in :erl_eval.expr/6> with arity 1 called with 2 arguments (1, 2)
    (stdlib 8.1) erl_eval.erl:1014: :erl_eval.do_apply/6
    iex:3: (file)
iex(3)> is_function(double, 1)
true
iex(4)> price = 5
5
iex(5)> price.(1)
** (BadFunctionError) expected a function, got: 5
    iex:5: (file)
iex(5)> System.halt()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 fn_session.py

IEx printed the function as #Function<42.130099583/1 in :erl_eval.expr/6>: a value like any other, made by the evaluator that runs IEx, and the /1 is its arity: The number of arguments a function takes, written after a slash: String.upcase/1 takes one argument, and an anonymous function printed as #Function<…/2 …> takes two., the number of arguments it takes. double.(5) returned 10. Without the dot, double(5) did not even compile: Elixir looked for a named function double/1 and found none. The Elixir guide gives the reason for the dot: with it, is_atom.(:foo) can never be mistaken for the named function is_atom(:foo), so nobody reading the code has to search for a variable of that name.

The two failed calls each raised their own error. double.(1, 2) passed two arguments to a function of arity 1 and raised BadArityError; price.(1) tried to call 5 and raised BadFunctionError, “expected a function, got: 5”. is_function(double, 1) checks both things at once: that the value is a function, and that it takes exactly one argument.

Calling a function value without the dot

double(5) asks for a named function called double, and none exists, so the line is refused before it runs. A name bound to a function is called with a dot, double.(5); a function defined in a module is called without one, String.upcase("pune").

Checkpoint

Check: Making and calling a function with fn and a dot

  1. Question 1 of 2 After double = fn x -> x * 2 end, what does the line double(5) do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: It does not compile, because there is no named function double/1

    A name bound to a function is called with a dot. Without the dot the compiler searches for a named function double/1, finds none and refuses the line before anything runs.

  2. Question 2 of 2 What happens on the second line?

    Read the code, then choose one answer.

    add = fn a, b -> a + b end
    add.(1)
    Show the answer: Question 2 of 2

    Answer: It raises BadArityError

    A call checks the arity before it tries any clause. add.(1) gives one argument to a function that takes two, so it raises BadArityError, with the function and the arguments in the message.

One function can have several clauses, all of the same arity

An anonymous function can hold several clauses, each with its own pattern and guard, tried from the top exactly as the clauses of case are. This module keeps one such function in describe and hands it to Enum.map/2, which calls it once for every reply in the list:

checkout.exs: one function, three clauses Elixir · checkout.exs
# One anonymous function with three clauses, used by Enum.map/2 on every reply.
defmodule Checkout do
  def messages(replies) do
    describe = fn
      {:ok, amount} when amount > 0 -> "paid #{amount}"
      {:ok, 0} -> "nothing to pay"
      {:error, reason} -> "failed: #{reason}"
    end

    Enum.map(replies, describe)
  end
end

IO.inspect(Checkout.messages([{:ok, 250}, {:ok, 0}, {:error, :card_declined}]))
IO.inspect(Checkout.messages([{:ok, 100}, :pending]))
arity_mix.exs: clauses of different arities Elixir · arity_mix.exs
# Every clause of one anonymous function must take the same number of arguments.
add = fn
  x -> x
  x, y -> x + y
end
Running the two scripts Python · checkout_session.py
from session import run

# The second list holds a reply that fits none of the clauses; arity_mix.exs does not compile.
run("""
!elixir checkout.exs
!elixir arity_mix.exs
""")

Output

$ elixir checkout.exs
["paid 250", "nothing to pay", "failed: card_declined"]
** (FunctionClauseError) no function clause matching in anonymous fn/1 in Checkout.messages/1

    The following arguments were given to anonymous fn/1 in Checkout.messages/1:

        # 1
        :pending

    anonymous fn/1 in Checkout.messages/1
    (elixir 1.20.4) lib/enum.ex:1725: Enum."-map/2-lists^map/1-1-"/2
    (elixir 1.20.4) lib/enum.ex:1725: Enum."-map/2-lists^map/1-1-"/2
    checkout.exs:15: (file)
[exit status 1]
$ elixir arity_mix.exs
    error: cannot mix clauses with different arities in anonymous functions
    │
  2 │ add = fn
    │       ^
    │
    └─ arity_mix.exs:2:7

** (CompileError) arity_mix.exs: cannot compile file (errors have been logged)
[exit status 1]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 checkout_session.py

{:ok, 250} fitted the first clause, whose guard wants an amount above 0. {:ok, 0} failed that guard and fitted the second clause, and {:error, :card_declined} the third. In the second list, :pending fitted no clause, so the call raised FunctionClauseError: The error a call raises when the number of arguments is right but none of the function's clauses fits them. The message names the function, such as anonymous fn/1 in Checkout.messages/1, and lists the arguments it was given.: “no function clause matching in anonymous fn/1 in Checkout.messages/1”, which names the function by its arity and the function that made it, followed by the argument it was given. The stack trace shows that Enum.map/2 made the call.

arity_mix.exs stopped before it ran: “cannot mix clauses with different arities in anonymous functions”. The guide states the rule: every clause of one anonymous function takes the same number of arguments, because a function value has one arity. Follow one call through every check it passes before its body runs:

Interactive · stage by stage describe.(reply): what a call checks before the body runs Step through one call. Each stage is a check, and each failed check stops the call with its own error.
describe.(reply): what a call checks before the body runs, step 5 of 5. Showing describe.(reply), Is it a function?, Does the arity match?, Does a clause fit?, The clause's body runs. In focus: The clause's body runs.describe.(reply)a dot: call the valueIs it a function?else BadFunctionErrorDoes the arity match?else BadArityErrorDoes a clause fit?else FunctionClauseErrorThe clause's body runsits value is the resultclause 2returns "nothing to pay"

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The call. describe.(reply) asks Elixir to call the value that the name describe holds. The dot says it is a value, not a named function of a module.
  2. A function?. First the value must be a function. describe holds one; price held 5, which is why price.(1) stopped with BadFunctionError, "expected a function, got: 5".
  3. The arity. Next the number of arguments must be its arity. describe takes one and gets one; double.(1, 2) gave two to a function of arity 1 and raised BadArityError.
  4. A clause that fits. Then the clauses are tried from the top, patterns and guards first. {:ok, 0} skips the clause for amounts above 0 and fits {:ok, 0}; :pending fits none and raises FunctionClauseError.
  5. The body runs. Only now does the body of the clause that fitted run, with the names its pattern bound. Its last value, "nothing to pay", is what the call returns.

A call meets each check in turn: a function, then the arity, then a clause that fits. The error tells you which check failed, so BadFunctionError, BadArityError and FunctionClauseError each point to a different mistake.

Checkpoint

Check: Clauses of one anonymous function

  1. Question 1 of 2 With the describe function of checkout.exs, what does this call do?

    Read the code, then choose one answer.

    describe = fn
      {:ok, amount} when amount > 0 -> "paid #{amount}"
      {:ok, 0} -> "nothing to pay"
      {:error, reason} -> "failed: #{reason}"
    end
    describe.({:ok, -5})
    Show the answer: Question 1 of 2

    Answer: It raises FunctionClauseError

    Clauses are tried from the top, each with its pattern and then its guard. {:ok, -5} fits none of the three, so the call raises FunctionClauseError with the argument it was given, as :pending did.

  2. Question 2 of 2 Why was arity_mix.exs refused before it ran?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: Its clauses take different numbers of arguments, and one anonymous function has one arity

    Every clause of one anonymous function takes the same number of arguments. For two arities, write two functions, or one function that takes a tuple or a list.

A closure keeps the values it saw

A function made with fn may use names from the code around it. This script makes a discount function that uses rate, then binds rate again before calling it:

closures.exs: a rate bound again, and a total that stays 0 Elixir · closures.exs
# A closure keeps the values its names had when it was made.
rate = 10
discount = fn paise -> paise - div(paise * rate, 100) end
rate = 50

IO.puts("rate is now #{rate}")
IO.puts("discount.(120_000) = #{discount.(120_000)}")

# A name bound inside a function stays inside it.
total = 0
Enum.each([300, 450, 250], fn amount -> total = total + amount end)
IO.puts("total after Enum.each: #{total}")

total = Enum.reduce([300, 450, 250], 0, fn amount, sum -> sum + amount end)
IO.puts("total after Enum.reduce: #{total}")
Running closures.exs Python · closures_session.py
from session import run

run("""
elixir closures.exs
""")

Output

$ elixir closures.exs
    warning: variable "total" is unused (there is a variable with the same name in the context, use the pin operator (^) to match on it or prefix this variable with underscore if it is not meant to be used)
    │
 11 │ Enum.each([300, 450, 250], fn amount -> total = total + amount end)
    │                                         ~
    │
    └─ closures.exs:11:41

rate is now 50
discount.(120_000) = 108000
total after Enum.each: 0
total after Enum.reduce: 1000

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 closures_session.py

The function returned 108000, 10 % off, although rate was 50 by the time it ran. When fn made the function, it kept the value that rate had at that moment, 10. A function that keeps values from where it was made is a closure, and the guide says such functions “close over their scope”. Binding rate again later gives the script’s name a new value; it does not reach into the function:

Interactive · step by step rate = 50 after the closure was made Step through closures.exs. The left column holds the script's names, the right column what the function keeps.
rate = 50 after the closure was made, step 4 of 4. Showing rate (no longer the name rate), discount, its rate: 10, rate, discount.(120_000), 108000. In focus: discount.(120_000), 108000.value copiedkeepsreturnsrateno longer the name ratediscountfn paise -> … rate …its rate: 10captured when fn ranratebound again to 50discount.(120_000)120_000 - 12_00010800010 % off, not 50 %

The drawing shows the whole scene; its 4 steps are listed below.

Every step as text
  1. rate = 10. rate = 10 binds the name rate to the value 10, as any match does.
  2. The function is made. fn paise -> paise - div(paise * rate, 100) end uses rate, so the function keeps the value rate has at this moment, 10. That kept value is what makes it a closure.
  3. rate = 50. rate = 50 binds the name again. The script now reads 50 for rate, but the function holds its own copy of the old value, and nothing reaches inside it.
  4. The call. discount.(120_000) runs the body with paise = 120_000 and the kept rate, 10: 120_000 - div(120_000 * 10, 100) = 108000, as the script printed.

The second half of the script shows the same rule from the inside. total = total + amount inside the function binds a new total that exists only for that one call, so Enum.each/2 left the outer total at 0. The compiler saw it before the script ran and warned that the inner total is unused, with “a variable with the same name in the context”.

Adding up inside Enum.each

Enum.each(amounts, fn amount -> total = total + amount end) never changes the outer total: it printed 0. Let the function return the new value and let Enum.reduce/3 carry it from one call to the next, as Enum.reduce(amounts, 0, fn amount, sum -> sum + amount end) does, which gave 1000.

The guide puts the rule plainly: “A variable assigned inside a function does not affect its surrounding environment.”

Key idea

A closure keeps the values its names had when fn made it: binding a name again later does not change the function, and a name bound inside the function stays inside it.

Checkpoint

Check: What a closure keeps

  1. Question 1 of 2 What does the last line return?

    Read the code, then choose one answer.

    n = 3
    times = fn x -> x * n end
    n = 4
    times.(10)
    Show the answer: Question 1 of 2

    Answer: 30

    A closure keeps the values its names had at the moment fn ran. Later bindings of the same names give the outer code new values and leave the function alone.

  2. Question 2 of 2 What does the last line return?

    Read the code, then choose one answer.

    count = 0
    Enum.each(["a", "b", "c"], fn _item -> count = count + 1 end)
    count
    Show the answer: Question 2 of 2

    Answer: 0

    A name bound inside a function stays inside it. To count, let the function return the new value and let Enum.reduce/3 carry it from call to call: Enum.reduce(items, 0, fn _item, n -> n + 1 end) gives 3.

A closure carries what it captured, wherever it goes

What a closure keeps travels with it: into a list, into another process, and across the network to another node. That is cheap for a number and expensive for a large value. This module makes two closures over the same data, one that needs the whole list of 10,000 prices and one that needs only its length, then measures each one:

carry.exs: two closures, one list Elixir · carry.exs
# What a closure carries: the values it captured, copied wherever the function goes.
defmodule Carry do
  def run do
    prices = Enum.to_list(1..10_000)
    count = length(prices)

    first_price = fn -> hd(prices) end
    how_many = fn -> count end

    IO.inspect(Function.info(how_many, :env), label: "how_many captured")
    IO.puts("first_price: #{:erlang.external_size(first_price)} bytes as data")
    IO.puts("how_many: #{:erlang.external_size(how_many)} bytes as data")
    IO.puts("a process running first_price: #{memory_of(first_price)} bytes")
    IO.puts("a process running how_many: #{memory_of(how_many)} bytes")
  end

  # Starts a process that runs `fun` and waits, then asks the VM how much memory that process uses.
  defp memory_of(fun) do
    parent = self()

    pid =
      spawn(fn ->
        fun.()
        send(parent, :ready)

        receive do
          :stop -> :ok
        end
      end)

    receive do
      :ready -> :ok
    end

    {:memory, bytes} = Process.info(pid, :memory)
    send(pid, :stop)
    bytes
  end
end

Carry.run()
Running carry.exs Python · carry_session.py
from session import run

run("""
elixir carry.exs
""")

Output

$ elixir carry.exs
how_many captured: {:env, [10000]}
first_price: 49321 bytes as data
how_many: 85 bytes as data
a process running first_price: 230280 bytes
a process running how_many: 2624 bytes

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 carry_session.py

Function.info(how_many, :env) shows what how_many captured: the single number 10000. The Function documentation calls :env the function’s environment, the outer variables it uses, and its note on Function.info/1 says to use it for debugging only. first_price uses prices, so it captured the whole list. :erlang.external_size/1, the size of a value in the format used to send it to another node, gave 49,321 bytes for first_price against 85 for how_many.

The last two lines start a process that runs each function, which is what Task.async/1 and spawn/1 do, and ask the VM how much memory that process uses. The process with first_price took 230,280 bytes, the one with how_many 2,624, about 88 times less, close to the 327 words, 2,616 bytes, that the Erlang efficiency guide gives for a newly spawned process. Each process has its own heap, and the guide lists the terms passed to a new process among the data that is copied, so every process started with first_price gets its own copy of the list.

Closure Captured As data · in a new process
fn -> hd(prices) end 10,000 prices 49,321 bytes · 230,280 bytes
fn -> count end the number 10000 85 bytes · 2,624 bytes

The habit that follows: work out what a function needs before you make it, such as count = length(prices) here, and let the closure capture that small value instead of the data it came from.

Checkpoint

Check: What a closure carries and costs

  1. Question 1 of 2 In carry.exs, why did the process running fn -> hd(prices) end take 230,280 bytes, and the one running fn -> count end only 2,624?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The first closure captured the whole list of 10,000 prices, which was copied into the new process

    A closure carries what it captured. :erlang.external_size/1 gave 49,321 bytes for the closure over the list and 85 for the closure over its length, and the two new processes took 230,280 and 2,624 bytes.

  2. Question 2 of 2 Each of 500 tasks needs only how many orders a 50,000-item list holds. What should the function you give each task capture?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A count worked out first, n = length(orders), so the closure uses only n

    Work out what the function needs before you make it, and let the closure capture that small value. The list is then counted once, not copied 500 times.

A capture names a function or writes a short one

Writing fn s -> String.upcase(s) end only to pass String.upcase/1 along is noise. The capture operator: &, which turns a named function into a function value, as in &String.upcase/1, or builds a short function from one expression with numbered arguments, as in &(&1 * 3), where &1 stands for the first argument. & turns a named function into a function value with &String.upcase/1, its module, name and arity. It also builds a short function from an expression with numbered arguments, where &1 stands for the first argument and &2 for the second:

Captures in IEx Python · capture_session.py
from session import shell

# & captures a named function by name and arity, or builds a short function from &1, &2 ...
# The last two captures are refused: one skips &1, the other holds a capture inside a capture.
shell([
    "upcase = &String.upcase/1",
    "upcase.(\"pune\")",
    "Function.info(upcase, :type)",
    "Enum.map([\"pune\", \"agra\"], &String.upcase/1)",
    "triple = &(&1 * 3)",
    "triple.(14)",
    "Function.info(&(&1 + &2), :arity)",
    "greet = &\"Good #{&1}\"",
    "greet.(\"morning\")",
    "Enum.map([1, 2, 3], &{&1, &1 * &1})",
    "&(&2 + 1)",
    "&(&1 + &(&2))",
    "System.halt()",
])

Output

$ iex
Erlang/OTP 29 [erts-17.1] [source] [64-bit] [smp:18:18] [ds:18:18:10] [async-threads:1] [jit]

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> upcase = &String.upcase/1
&String.upcase/1
iex(2)> upcase.("pune")
"PUNE"
iex(3)> Function.info(upcase, :type)
{:type, :external}
iex(4)> Enum.map(["pune", "agra"], &String.upcase/1)
["PUNE", "AGRA"]
iex(5)> triple = &(&1 * 3)
#Function<42.130099583/1 in :erl_eval.expr/6>
iex(6)> triple.(14)
42
iex(7)> Function.info(&(&1 + &2), :arity)
{:arity, 2}
iex(8)> greet = &"Good #{&1}"
#Function<42.130099583/1 in :erl_eval.expr/6>
iex(9)> greet.("morning")
"Good morning"
iex(10)> Enum.map([1, 2, 3], &{&1, &1 * &1})
[{1, 1}, {2, 4}, {3, 9}]
iex(11)> &(&2 + 1)
error: capture argument &2 cannot be defined without &1 (you cannot skip arguments, all arguments must be numbered)
└─ iex:11

** (CompileError) cannot compile code (errors have been logged)

iex(11)> &(&1 + &(&2))
error: nested captures are not allowed. You cannot define a function using the capture operator & inside another function defined via &. Got invalid nested capture: & &2
└─ iex:11

** (CompileError) cannot compile code (errors have been logged)

iex(11)> System.halt()

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 capture_session.py

&String.upcase/1 printed as itself, and Function.info/2 called it :external: a reference to a function defined in a module, as the Function documentation describes it. &(&1 * 3) is a function of arity 1, and &(&1 + &2) of arity 2, because the highest numbered argument decides the arity. The guide says &(&1 + 1) is exactly the same as fn x -> x + 1 end, and the same shorthand built a string and a tuple in the session:

Capture Same as Arity
&String.upcase/1 fn s -> String.upcase(s) end 1
&(&1 * 3) fn x -> x * 3 end 1
&(&1 + &2) fn a, b -> a + b end 2
&"Good #{&1}" fn p -> "Good #{p}" end 1
&{&1, &1 * &1} fn n -> {n, n * n} end 1

The last two captures of the session were refused. &(&2 + 1) skips &1, and the compiler wants every argument up to the highest one numbered. &(&1 + &(&2)) puts a capture inside a capture, and the compiler answered that “nested captures are not allowed”. For anything longer than a short expression, write fn, which can also name its arguments and have clauses.

Checkpoint

Check: Captures with & and &1

  1. Question 1 of 2 What is the arity of &(&1 * &2 + &3)?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: 3

    A capture with numbered arguments takes as many arguments as its highest number, and it must use every number up to that one.

  2. Question 2 of 2 Which function is the same as &String.split(&1, ",")?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: fn text -> String.split(text, ",") end

    &( … &1 … ) builds a function whose argument goes where &1 stands. It is the same as writing fn with that argument named.

Functions are values: keep them, pass them, return them

A function is a value, so everything that holds values can hold functions. Here are the discount rules of the opening as a map from codes to functions. One rule is a fn, one a capture, and one is made by another function, percent_off/1, which returns a closure over the percentage it was given:

discounts.exs: rules in a map Elixir · discounts.exs
# Discount rules kept as functions: stored in a map, made by a function, passed to Enum.map/2.
defmodule Discounts do
  def percent_off(percent) do
    fn paise -> paise - div(paise * percent, 100) end
  end

  def rules do
    %{
      "NONE" => fn paise -> paise end,
      "TEN" => percent_off(10),
      "FLAT150" => &max(&1 - 15_000, 0)
    }
  end

  def apply_code(code, paise) do
    case Map.fetch(rules(), code) do
      {:ok, rule} -> {:ok, rule.(paise)}
      :error -> {:error, :unknown_code}
    end
  end
end

for code <- ["NONE", "TEN", "FLAT150", "HALF"] do
  IO.inspect(Discounts.apply_code(code, 120_000), label: code)
end

IO.inspect(Enum.map([50_000, 120_000], Discounts.percent_off(20)), label: "20% off each")
Running discounts.exs Python · discounts_session.py
from session import run

run("""
elixir discounts.exs
""")

Output

$ elixir discounts.exs
NONE: {:ok, 120000}
TEN: {:ok, 108000}
FLAT150: {:ok, 105000}
HALF: {:error, :unknown_code}
20% off each: [40000, 96000]

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 discounts_session.py

Every code took the same path through apply_code/2: find the rule, call it with a dot, wrap the result. “TEN” gave 108000 and “FLAT150” 105000, while “HALF”, which the map does not hold, gave {:error, :unknown_code}. The last line passed the function that percent_off(20) returned straight to Enum.map/2, which applied 20 % off to each price. Step through one lookup:

Interactive · step by step apply_code("TEN", 120_000): a rule taken from a map and called Step through two calls. The left column is what apply_code/2 does, the right column the values it gets.
apply_code("TEN", 120_000): a rule taken from a map and called, step 5 of 5. Showing rules(), Map.fetch(rules, "TEN"), rule = percent_off(10), rule.(120_000), {:ok, 108000}, {:error, :unknown_code}. In focus: {:error, :unknown_code}.{:ok, rule}:errorrules()a map of 3 functionsMap.fetch(rules, "TEN")looks the code uprule = percent_off(10)a closure, percent = 10rule.(120_000)the call, with a dot{:ok, 108000}what apply_code returns{:error, :unknown_code}for "HALF""HALF"

The drawing shows the whole scene; its 5 steps are listed below.

Every step as text
  1. The rules. rules() builds a map from codes to functions: a fn for "NONE", the closure that percent_off(10) returns for "TEN", and a capture for "FLAT150". Functions are values, so a map can hold them.
  2. The code is looked up. Map.fetch/2 looks "TEN" up and returns {:ok, rule}. The case in apply_code/2 matches it and binds rule to the function.
  3. The rule is called. rule.(120_000) calls the function with a dot. It is the closure from percent_off(10), so it still has percent = 10 and returns 108000.
  4. The result. apply_code/2 wraps the price in {:ok, 108000}. The same three lines work for every rule the map will ever hold, with no case on the code.
  5. A code the map lacks. For "HALF", Map.fetch/2 returns :error, so the second clause of the case returns {:error, :unknown_code} instead of calling nil, which would raise BadFunctionError.

Map.fetch/2 is what keeps the missing code safe. rules()["HALF"] would return nil, and nil.(120_000) would raise BadFunctionError, the error price.(1) raised. A new rule is now one more entry in the map, and no case anywhere has to change.

Checkpoint

Check: Functions kept, passed and returned

  1. Question 1 of 2 What does the last line return?

    Read the code, then choose one answer.

    make_adder = fn n -> fn x -> x + n end end
    add5 = make_adder.(5)
    add5.(1)
    Show the answer: Question 1 of 2

    Answer: 6

    A function can return a function, as percent_off(20) did. The returned function is a closure over the argument of the call that made it.

  2. Question 2 of 2 With the rules map of discounts.exs, what does rules()["HALF"].(120_000) do?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It raises BadFunctionError, because rules()["HALF"] is nil

    Map.fetch/2 tells a missing code apart with :error, so apply_code/2 can answer with an error tuple instead of calling nil.

Practise: compose and partial

Write two functions that build functions. Fun.compose/1 takes a list of one-argument functions and returns one function that applies them all, the first one first: compose([f, g]) does what fn x -> g.(f.(x)) end does, and compose([]) gives its argument back. Fun.partial/2 takes a two-argument function and a first argument and returns a function of the second: partial(fun, a).(b) gives fun.(a, b). Copy the two files into an empty folder:

practice/fun.ex: the starter Elixir · practice/fun.ex
defmodule Fun do
  @moduledoc "Building new functions out of other functions."

  @doc """
  One function that applies every function of `funs` in turn, the first one first: compose([f, g]) does what
  fn x -> g.(f.(x)) end does. With no functions, the value comes back unchanged.
  """
  def compose(_funs) do
    # Drop the underscore once you use the argument.
    :todo
  end

  @doc """
  A one-argument function that calls the two-argument function `fun` with `first` as its first argument:
  partial(fun, a).(b) gives fun.(a, b).
  """
  def partial(_fun, _first) do
    :todo
  end
end
practice/fun_test.exs: the tests Elixir · practice/fun_test.exs
Code.require_file("fun.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule FunTest do
  use ExUnit.Case

  test "compose applies the functions from the first to the last" do
    add_one_then_tenfold = Fun.compose([&(&1 + 1), &(&1 * 10)])
    assert add_one_then_tenfold.(2) == 30
  end

  test "compose works with captures of named functions" do
    clean = Fun.compose([&String.trim/1, &String.upcase/1])
    assert clean.("  pune ") == "PUNE"
  end

  test "compose of no functions gives the value back" do
    assert Fun.compose([]).(:same) == :same
  end

  test "partial fixes the first argument" do
    from_ten = Fun.partial(&-/2, 10)
    assert from_ten.(3) == 7
  end

  test "partial returns a function of one argument" do
    assert is_function(Fun.partial(&+/2, 1), 1)
  end

  test "partial works with a named function" do
    repeat_ab = Fun.partial(&String.duplicate/2, "ab")
    assert repeat_ab.(3) == "ababab"
  end
end

Run elixir fun_test.exs. The starter returns :todo instead of a function, so five tests fail with BadFunctionError, and the one that asks is_function/2 fails its assertion:

The tests against the starter Python · practice_session.py
from session import run

# The tests against the starter, which builds no functions yet.
run("""
cd practice
!elixir fun_test.exs
""")

Output

$ cd practice
$ elixir fun_test.exs
Running ExUnit with seed: 0, max_cases: 36



  1) test compose applies the functions from the first to the last (FunTest)
     fun_test.exs:7
     ** (BadFunctionError) expected a function, got: :todo
     code: assert add_one_then_tenfold.(2) == 30
     stacktrace:
       fun_test.exs:9: (test)



  2) test compose works with captures of named functions (FunTest)
     fun_test.exs:12
     ** (BadFunctionError) expected a function, got: :todo
     code: assert clean.("  pune ") == "PUNE"
     stacktrace:
       fun_test.exs:14: (test)



  3) test compose of no functions gives the value back (FunTest)
     fun_test.exs:17
     ** (BadFunctionError) expected a function, got: :todo
     code: assert Fun.compose([]).(:same) == :same
     stacktrace:
       fun_test.exs:18: (test)



  4) test partial fixes the first argument (FunTest)
     fun_test.exs:21
     ** (BadFunctionError) expected a function, got: :todo
     code: assert from_ten.(3) == 7
     stacktrace:
       fun_test.exs:23: (test)



  5) test partial returns a function of one argument (FunTest)
     fun_test.exs:26
     Expected truthy, got false
     code: assert is_function(Fun.partial(&+/2, 1), 1)
     arguments:

         # 1
         :todo

         # 2
         1

     stacktrace:
       fun_test.exs:27: (test)



  6) test partial works with a named function (FunTest)
     fun_test.exs:30
     ** (BadFunctionError) expected a function, got: :todo
     code: assert repeat_ab.(3) == "ababab"
     stacktrace:
       fun_test.exs:32: (test)


Finished in 0.02 seconds (0.02s on load, 0.00s async, 0.00s sync)

Result: 0/6 passed
Failed: 6 tests
[exit status 2]

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 practice_session.py

For compose/1, return a fn of one value that runs Enum.reduce/3 over the list, with the value as the starting accumulator and each function applied to the result so far; an empty list then leaves the value as it was. For partial/2, return a fn of the second argument that calls the function with both. Both answers are closures: the functions they return keep funs, or fun and first, from the call that made them. A finished version passes all six tests:

The tests against finished compose/1 and partial/2 Python · finished_session.py
from session import run

# The same tests against finished compose/1 and partial/2.
run("""
cd finished
cp ../practice/fun_test.exs .
elixir fun_test.exs
""")

Output

$ cd finished
$ cp ../practice/fun_test.exs .
$ elixir fun_test.exs
Running ExUnit with seed: 0, max_cases: 36

......
Finished in 0.02 seconds (0.02s on load, 0.00s async, 0.00s sync)

Result: 6 passed

This output changes from run to run: ExUnit prints how long the tests took, and max_cases follows the computer's processors

Recorded with Python 3.14.8 on macOS 26 arm64. To run it yourself: mise exec python@3.14.8 -- python3 finished_session.py

To finish, a self-check on calls, clauses, closures, what closures carry, captures and functions as data. Whichever of those your answers show as shakiest is asked about first, with every choice explained right after you make it:

Practice that adapts to you

Practise this lesson

18 questions about 6 ideas of this lesson. Each answer is explained at once, and what you get right is kept in this browser.

  1. Question 1 of 18 IEx printed a function as #Function<42.130099583/1 in :erl_eval.expr/6>. What does the /1 say?

    Choose one answer.

    Show the answer: Question 1 of 18

    Answer: The function takes one argument

    Functions are named by their arity: double/1 takes one argument, and is_function(double, 1) checks exactly that.

  2. Question 2 of 18 What does this return?

    Read the code, then choose one answer.

    is_function(fn a, b -> a + b end, 1)
    Show the answer: Question 2 of 18

    Answer: false

    is_function(value, n) is true only for a function that takes exactly n arguments.

  3. Question 3 of 18 A log shows ** (BadFunctionError) expected a function, got: nil. Which line most likely caused it?

    Choose one answer.

    Show the answer: Question 3 of 18

    Answer: A call with a dot on a value looked up from a map with a key it does not hold

    BadFunctionError means the value before the dot was not a function at all. nil usually comes from a lookup that found nothing.

  4. Question 4 of 18 What does this call return?

    Read the code, then choose one answer.

    kind = fn
      {:ok, _} -> :any_ok
      {:ok, 0} -> :zero
    end
    kind.({:ok, 0})
    Show the answer: Question 4 of 18

    Answer: :any_ok

    As in case, specific clauses go above general ones. Here the second clause can never run.

  5. Question 5 of 18 Which error does a call raise when the function's arity is right but none of its clauses fits the argument?

    Choose one answer.

    Show the answer: Question 5 of 18

    Answer: FunctionClauseError

    FunctionClauseError names the function and lists the arguments it was given, so the value that fitted nothing is right in the message.

  6. Question 6 of 18 What does this call return?

    Read the code, then choose one answer.

    sign = fn
      x when is_integer(x) and x > 0 -> :positive
      x when is_integer(x) -> :not_positive
      _ -> :not_an_integer
    end
    sign.(2.5)
    Show the answer: Question 6 of 18

    Answer: :not_an_integer

    A clause runs only when its pattern fits and its whole guard is true. A float is not an integer, whatever its value.

  7. Question 7 of 18 What does a closure keep?

    Choose one answer.

    Show the answer: Question 7 of 18

    Answer: The values its names had when fn made it

    A closure keeps values, captured at the moment fn ran. That is why a closure behaves the same wherever and whenever it is called.

  8. Question 8 of 18 What does the last line return?

    Read the code, then choose one answer.

    greeting = "Namaste"
    say = fn name -> greeting <> ", " <> name end
    greeting = "Bye"
    say.("Asha")
    Show the answer: Question 8 of 18

    Answer: "Namaste, Asha"

    The function was made while greeting was "Namaste", and that is the value it keeps.

  9. Question 9 of 18 What do the two calls return, and what is count afterwards?

    Read the code, then choose one answer.

    count = 0
    bump = fn -> count = count + 1 end
    first = bump.()
    second = bump.()
    {first, second, count}
    Show the answer: Question 9 of 18

    Answer: {1, 1, 0}

    A function returns the value of its last expression, here 1, and keeps nothing between calls. A counter needs its value passed in and returned, as Enum.reduce/3 does.

  10. Question 10 of 18 A process running first_price took 230,280 bytes and one running how_many 2,624. Roughly how many times more memory did the first take? Give the nearest whole number.

    Type a number.

    A hint

    Divide the larger number by the smaller.

    Show the answer: Question 10 of 18

    Answer: 88 times (anything from 87 to 89 counts)

    230,280 / 2,624 is about 87.8, so roughly 88 times. The difference is the list of 10,000 prices that the first closure captured and the new process copied.

  11. Question 11 of 18 Inside a function with catalog (a big map) and limit = 20 in scope, which closure captures the whole catalog?

    Choose one answer.

    Show the answer: Question 11 of 18

    Answer: fn item -> Map.has_key?(catalog, item) end

    A closure captures the names its body uses. Function.info(fun, :env) shows them, for debugging.

  12. Question 12 of 18 Why does the size of what a closure captured matter when the closure is given to Task.async/1?

    Choose one answer.

    Show the answer: Question 12 of 18

    Answer: The task runs in a new process, and the captured values are copied into that process's heap

    Each process has its own heap. A closure's captured values travel with it into every process that runs it, and over the network to other nodes.

  13. Question 13 of 18 What does Function.info(&String.upcase/1, :type) return?

    Choose one answer.

    Show the answer: Question 13 of 18

    Answer: {:type, :external}

    &Mod.fun/arity refers to a named function, so it is :external; the functions that fn and &(&1 …) build are :local.

  14. Question 14 of 18 Why is &(&2 * 2) refused?

    Choose one answer.

    Show the answer: Question 14 of 18

    Answer: It uses &2 without &1, and a capture may not skip arguments

    Every numbered argument up to the highest must appear. For a function that ignores its first argument, write fn _a, b -> b * 2 end.

  15. Question 15 of 18 You need a function that gives :ok for {:ok, _} and :error for anything else. Which way of writing it works?

    Choose one answer.

    Show the answer: Question 15 of 18

    Answer: fn {:ok, _} -> :ok; _ -> :error end

    Use & for a named function or one short expression, and fn when the function needs clauses, guards or names for its arguments.

  16. Question 16 of 18 What does percent_off(10) in discounts.exs return?

    Choose one answer.

    Show the answer: Question 16 of 18

    Answer: A function that takes a price and returns it with 10 % off

    Functions can return functions. percent_off(20) returned the one that Enum.map/2 then applied to each price.

  17. Question 17 of 18 With percent_off/1 of discounts.exs, what does this return?

    Read the code, then choose one answer.

    Enum.map([1_000, 3_000], Discounts.percent_off(50))
    Show the answer: Question 17 of 18

    Answer: [500, 1500]

    Enum.map/2 takes any function of one argument, including one that another function returned.

  18. Question 18 of 18 Why does apply_code/2 look the code up with Map.fetch/2 rather than calling rules()[code].(paise)?

    Choose one answer.

    Show the answer: Question 18 of 18

    Answer: A missing code would give nil, and calling nil raises BadFunctionError; Map.fetch/2 returns :error instead

    Functions in maps are looked up like any values, so plan for the key that is not there.

Interview questions

Warm-up: what is a closure in Elixir, and what does it capture?

A closure is a function that keeps values from the place where it was made. When fn creates a function that uses a name from the code around it, such as rate in fn paise -> paise - div(paise * rate, 100) end, the function keeps the value that name had at that moment, 10 here. Binding rate again later, to 50, gives the outer name a new value and leaves the function’s copy alone, so the call still takes 10 % off. The rule works in the other direction too: a name bound inside the function, such as total = total + amount, stays inside that call. Because it keeps values, a closure over a large list carries the whole list into every process that runs it, so capture the small value you need, such as the list’s length.

Two questions from code that passes functions around:

Model answers open with any pass: ₹79 for 1 week ($2.99), one payment that never renews.

  1. Mid to senior

    A report starts one Task.async/1 for each of 2,000 orders with fn -> score(order, prices) end, where prices is a list of 10,000 integers loaded once. Memory climbs by hundreds of megabytes on every run. Explain what each task receives, estimate the cost from the lesson's measurements, and redesign the code.

    Model answer, Pro. Model answers open with any pass; prices are in the notice on this page. First step and follow-ups

  2. Fresher to mid

    A teammate adds up an order list with total = 0 and Enum.each(orders, fn o -> total = total + o.amount end), and the report always prints 0. They suggest keeping the total somewhere global instead. Explain what happens, what the compiler already said, and how you would write it.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • fn makes a function value, and a name bound to one is called with a dot, double.(5); without the dot Elixir looks for a named function instead.
  • A call fails with BadFunctionError when the value is not a function, BadArityError when the number of arguments differs, and FunctionClauseError when no clause fits.
  • An anonymous function may have several clauses with patterns and guards, tried from the top, and all of them take the same number of arguments.
  • A closure keeps the values its names had when it was made: binding a name again later changes nothing in it, and a name bound inside it stays inside.
  • What a closure captures travels with it, so a closure over a 10,000-item list took 230,280 bytes in a new process against 2,624 for one over the list’s length.
  • &Mod.fun/arity captures a named function and &(&1 * 3) writes a short one, and functions are values that maps can hold, Enum can call and functions can return.

Terms in this lesson

anonymous function
A function value without a name, made with fn … end or with &, such as fn x -> x * 2 end. A name bound to one is called with a dot, double.(5), and the value can be stored, passed and returned like any other.
arity
The number of arguments a function takes, written after a slash: String.upcase/1 takes one argument, and an anonymous function printed as #Function<…/2 …> takes two.
capture operator
&, which turns a named function into a function value, as in &String.upcase/1, or builds a short function from one expression with numbered arguments, as in &(&1 * 3), where &1 stands for the first argument.
closure
A function that keeps the values its names had when it was made. After rate = 10, fn p -> p * rate end keeps 10 even when rate is bound to 50 later, and it carries that value into every process that runs it.
FunctionClauseError
The error a call raises when the number of arguments is right but none of the function's clauses fits them. The message names the function, such as anonymous fn/1 in Checkout.messages/1, and lists the arguments it was given.

References

Report a problem with this lesson

Quick answers and tool search

Type to search tools or to get a quick answer, for example 18% of 2500. Use the up and down arrow keys to move through the results, Enter to choose, and Escape to close.