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 5 – Recursion, collections and data modelling

Recursion, tail calls and fair measurement

Write recursive Elixir functions with base cases and accumulators, see when a tail call keeps the stack flat, and compare two versions fairly.

  • Intermediate
  • 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

  • Write body-recursive and tail-recursive functions with a base case and an accumulator
  • Predict how deep the stack of a recursive call grows, and what stops a process that goes too deep
  • Explain when a tail call matters on the BEAM and when it does not
  • Benchmark two versions fairly, with a new process for every run, many runs, the median and the fastest

Before you start

What you’ll learn · Before you start

What you will learn

  • Write body-recursive and tail-recursive functions with a base case and an accumulator
  • Predict how deep the stack of a recursive call grows, and what stops a process that goes too deep
  • Explain when a tail call matters on the BEAM and when it does not
  • Benchmark two versions fairly, with a new process for every run, many runs, the median and the fastest

Before you start

On this page

Adding up the numbers 1 to 10,000,000 with a function that calls itself held 20,000,019 words of stack, 160 MB, at its deepest point; the same sum written with a tail call needed 19 words, whatever the count. Elixir has no loop that counts a variable up, so every loop is a function that calls itself, and the way it calls itself decides what it costs.

Below, you will write recursive functions with a base case and an accumulator, watch one stack grow and another stay flat, see a process killed by its own stack, find the one place where a tail call is required, and measure two versions in a way that does not fool you. Every output here comes from Elixir 1.20.4 on Erlang/OTP 29.1.1.

A recursive function needs a base case and a smaller call

Elixir’s data never changes, so a loop cannot step a counter through a list. The Elixir guide names what it does instead: “Recursion and tail call optimization are an important part of Elixir and are commonly used to create loops.” A recursive function handles the simplest input in one clause and, in another, does one step of the work and calls itself on what is left. Bill.total/1 adds up a bill’s prices in rupees:

bill.ex Elixir · bill.ex
# A bill's prices in rupees, added up by a function that calls itself.
defmodule Bill do
  # The base case: an empty bill adds up to 0.
  def total([]), do: 0
  # The recursive case: the first price, plus the total of the rest.
  def total([price | rest]), do: price + total(rest)

  # The same function without its base case.
  def total_no_base([price | rest]), do: price + total_no_base(rest)
end

The first clause matches the empty list and returns 0: that is the base case: The clause of a recursive function that returns without calling the function again, such as total([]) returning 0. Every chain of recursive calls has to reach one, or it fails with FunctionClauseError or never ends.. The second matches a list with at least one price, splits it into price and rest, as [head | tail] did in the lists lesson, and returns price + total(rest). Each call works on a list one item shorter, so the calls must reach the empty list. Try it in IEx:

Bill.total/1 in IEx Python · bill_session.py
from session import shell

# Bill.total/1 in IEx, then the same function without its base case.
shell([
    "Bill.total([120, 45, 300])",
    "Bill.total([])",
    "Bill.total_no_base([120, 45, 300])",
    "System.halt()",
], command="iex bill.ex")

Output

$ iex bill.ex
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)> Bill.total([120, 45, 300])
465
iex(2)> Bill.total([])
0
iex(3)> Bill.total_no_base([120, 45, 300])
** (FunctionClauseError) no function clause matching in Bill.total_no_base/1

    The following arguments were given to Bill.total_no_base/1:

        # 1
        []

    bill.ex:9: Bill.total_no_base/1
    iex:3: (file)
iex(3)> System.halt()

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

Bill.total([120, 45, 300]) returned 465, and the empty bill returned 0. total_no_base/1 is the same function without the clause for []: it worked through the three prices, called itself with the empty rest, which no clause matches, and raised FunctionClauseError with [] as its argument. Step through the call that worked, down to the base case and back up:

Interactive · stage by stage Bill.total([120, 45, 300]): down to the base case and back Step through the call. Each stage is one call of total/1, and each addition waits until the call below it returns.
Bill.total([120, 45, 300]): down to the base case and back, step 8 of 8. Showing total([120, 45, 300]) (120 + 345 = 465), total([45, 300]) (45 + 300 = 345), total([300]) (300 + 0 = 300), total([]) (no clause fits []). In focus: total([]).returns 0returns 300returns 345total([120, 45, 300])120 + 345 = 465total([45, 300])45 + 300 = 345total([300])300 + 0 = 300total([])no clause fits []

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

Every step as text
  1. The first call. The list [120, 45, 300] does not match [], so the second clause runs, with price 120 and rest [45, 300]. It must call total([45, 300]) before it can add, so the addition waits.
  2. One price shorter. total([45, 300]) runs the same clause on a list one item shorter: price 45, rest [300]. Now two additions are waiting.
  3. The last price. total([300]) takes the last price and calls total([]) with the empty rest. Three additions are waiting, one for each price.
  4. The base case. total([]) matches the first clause, the base case, and returns 0 without calling anything. This is where the calls stop going down.
  5. 300 + 0. Now the calls return in the opposite order. total([300]) receives 0, finishes its addition and returns 300.
  6. 45 + 300. total([45, 300]) receives 300 and returns 345.
  7. 120 + 345. The first call receives 345, adds its 120 and returns 465, the total that IEx printed.
  8. Without a base case. total_no_base/1 makes the same three calls, but nothing matches the fourth one, with []. It raises FunctionClauseError and names [] as the argument, as the session showed.

Each addition waits for the call below it: 120 + total([45, 300]) cannot add until total([45, 300]) has returned. This shape, with work left to do after the recursive call, is body recursion: Recursion with work left to do after the recursive call returns, as in price + total(rest). Each waiting call keeps a frame on the process's stack until the calls below it have returned.. It is the way most people first write a recursive function, and it reads like the definition: the total is the first price plus the total of the rest.

A base case the calls never reach

Leave out the clause for [] and the function fails at the end of every list, as total_no_base/1 did. Make a call that does not shrink its input, such as total(prices) instead of total(rest), and it never ends. Write the base case first, then check that every recursive call moves one step towards it.

Checkpoint

Check: Base case and recursive case

  1. One question What happens when this runs?

    Read the code, then choose one answer.

    defmodule Count do
      def down(n) when n > 0, do: [n | down(n - 1)]
    end
    
    Count.down(3)
    Show the answer: One question

    Answer: It raises FunctionClauseError when the calls reach down(0)

    Every recursive call has to end in a base case that some input of the recursion reaches. Here the calls count down to 0, and with no clause for 0 the fourth call raises FunctionClauseError. Adding def down(0), do: [] makes it return [3, 2, 1].

A tail call leaves nothing to do, so the stack stays flat

Every call that has not returned yet keeps a frame on the process’s stack: what it still has to do, and the values it needs to do it. 120 + total([45, 300]) keeps 120 there until the call below returns. A call that is the last thing a function does needs no such frame, so the BEAM reuses the caller’s frame for it: that is a tail call: A call that is the last thing a function does, so nothing is left to do when it returns. The BEAM reuses the caller's stack frame for it, so a function that calls itself only this way runs in a stack of fixed size.. To make the recursive call the last thing a clause does, carry the result so far in an extra argument, an accumulator: An extra argument that carries the result built so far through each recursive call, such as a running total. The base case returns it; a list built in one by prepending comes out in reverse order.:

bill_acc.ex Elixir · bill_acc.ex
# The bill again, added up and converted by tail-recursive functions that carry
# their result in an accumulator.
defmodule BillAcc do
  # The running total travels in acc, so the recursive call is the last thing
  # each clause does.
  def total(prices), do: total(prices, 0)

  defp total([], acc), do: acc
  defp total([price | rest], acc), do: total(rest, acc + price)

  # Prepending each new item to the accumulator builds the list back to front.
  def in_paise_backwards(prices), do: in_paise(prices, [])

  # The fix: reverse the finished list once.
  def in_paise(prices), do: Enum.reverse(in_paise(prices, []))

  defp in_paise([], acc), do: acc
  defp in_paise([price | rest], acc), do: in_paise(rest, [price * 100 | acc])
end
The accumulator versions in IEx Python · bill_acc_session.py
from session import shell

# The tail-recursive total, then a list built in an accumulator, without and with the reverse.
shell([
    "BillAcc.total([120, 45, 300])",
    "BillAcc.in_paise_backwards([120, 45, 300])",
    "BillAcc.in_paise([120, 45, 300])",
    "System.halt()",
], command="iex bill_acc.ex")

Output

$ iex bill_acc.ex
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)> BillAcc.total([120, 45, 300])
465
iex(2)> BillAcc.in_paise_backwards([120, 45, 300])
[30000, 4500, 12000]
iex(3)> BillAcc.in_paise([120, 45, 300])
[12000, 4500, 30000]
iex(4)> System.halt()

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

BillAcc.total/1 starts total/2 with an accumulator of 0, and each clause adds one price to it on the way down, so the base case already holds the answer, 465, and returns it. Nothing waits for any call. Step through both versions side by side and count the frames each one keeps:

Interactive · step by step Frames on the stack: Bill.total/1 against BillAcc.total/2 Step through both versions on the bill [120, 45, 300]. Each box is a frame, a call that has not returned yet.
Frames on the stack: Bill.total/1 against BillAcc.total/2, step 6 of 6. Showing total([120, 45, 300]) (returns 120 + 345 = 465), total/2: one frame (returned 465). In focus: total([120, 45, 300]).total([120, 45, 300])returns 120 + 345 = 465total/2: one framereturned 465

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

Every step as text
  1. One call each. Both versions start on the whole bill. The frame of total/1 must stay to add 120 later. The frame of total/2 holds what is left of the list and the running total, acc 0.
  2. The second price. total/1 calls total([45, 300]) and keeps its own frame waiting: two frames. The last thing total/2 does is call itself, so the new call takes the old frame: still one frame, now with acc 120.
  3. The third price. Three frames wait in total/1, one for each price so far. total/2 has one frame, with acc 165.
  4. The base cases. Both reach the empty list. total/1 is four frames deep, one for each price and one for []. total/2 still has one frame, and acc already holds the answer, 465.
  5. total/2 is done. total/2 returns acc, 465, straight to IEx: no frame was waiting for it. total/1 now has three additions to do, from the bottom up.
  6. Back to the top. The waiting frames finish one by one, 300, then 345, then 465. Each waiting call costs stack: depth.exs below measures 2 words for each one.

An accumulator has one catch for lists: each new item is prepended, so the list comes out backwards. in_paise_backwards/1 returned [30000, 4500, 12000], the last price first. in_paise/1 reverses the finished list once with Enum.reverse/1 and returns [12000, 4500, 30000]. The reverse is one more pass over the list, far cheaper than adding each item at the end with ++, as the lists lesson measured: prepend, then reverse once.

Reversing at the wrong moment

Forget the reverse and every list comes out backwards; reverse inside the recursive clause and each step copies the whole list built so far, which makes the function quadratic. Reverse once, in the clause that returns.

How much does a waiting frame cost? depth.exs adds up 1 to n both ways, each in a new process, and records the size of that process’s stack, in words, at the base case, where it is deepest:

depth.exs Elixir · depth.exs
# Adds up 1 to n twice, by body recursion and by tail recursion, each in a new
# process, and records how deep the stack of that process got.
defmodule Sum do
  # Body recursion: n + ... waits until body(n - 1) has returned.
  def body(0), do: deepest(0)
  def body(n), do: n + body(n - 1)

  # Tail recursion: the call is the last thing the clause does.
  def tail(n), do: tail(n, 0)
  defp tail(0, acc), do: deepest(acc)
  defp tail(n, acc), do: tail(n - 1, acc + n)

  # Called at the base case, where the stack is as deep as it gets: keeps the
  # size of the stack, in words, in the process dictionary.
  defp deepest(value) do
    {:stack_size, words} = Process.info(self(), :stack_size)
    Process.put(:deepest, words)
    value
  end
end

defmodule Main do
  # Runs fun in a new process; returns its result and the deepest stack it saw.
  def measure(fun) do
    task = Task.async(fn -> {fun.(), Process.get(:deepest)} end)
    Task.await(task, :infinity)
  end

  def run do
    IO.puts("         n             sum  body: stack words  tail: stack words")

    for n <- [10, 1_000, 1_000_000, 10_000_000] do
      {sum, body_words} = measure(fn -> Sum.body(n) end)
      {^sum, tail_words} = measure(fn -> Sum.tail(n) end)

      IO.puts(
        pad(n, 10) <> pad(sum, 16) <> pad(body_words, 19) <> pad(tail_words, 19)
      )
    end

    IO.puts("one word is #{:erlang.system_info(:wordsize)} bytes")
  end

  defp pad(number, width), do: number |> Integer.to_string() |> String.pad_leading(width)
end

Main.run()
The deepest stack of each version Python · depth_session.py
from session import run

run("""
elixir depth.exs
""")

Output

$ elixir depth.exs
         n             sum  body: stack words  tail: stack words
        10              55                 39                 19
      1000          500500               2019                 19
   1000000    500000500000            2000019                 19
  10000000  50000005000000           20000019                 19
one word is 8 bytes

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

The body version’s stack grew by exactly 2 words for each waiting call, 2n + 19 in all: 2,000,019 words for a million numbers and 20,000,019 words, 160 MB with 8-byte words, for ten million. The tail version used 19 words for every n.

Key idea

A call is a tail call when nothing is left to do after it returns. The BEAM then reuses the frame, so a function that calls itself only in tail position runs in a stack of fixed size, however long its input.

Checkpoint

Check: Tail calls and accumulators

  1. Question 1 of 2 Which of these clauses ends in a tail call?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: defp len([_ | rest], n), do: len(rest, n + 1)

    A call is in tail position when its result is the function's result, with nothing left to do. Work done on the arguments before the call, such as n + 1 or acc + price, does not count; work done on the result after it does.

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

    Read the code, then choose one answer.

    defmodule Evens do
      def keep(list), do: keep(list, [])
    
      defp keep([], acc), do: acc
      defp keep([x | rest], acc) when rem(x, 2) == 0, do: keep(rest, [x | acc])
      defp keep([_ | rest], acc), do: keep(rest, acc)
    end
    
    Evens.keep([2, 3, 4, 6])
    Show the answer: Question 2 of 2

    Answer: [6, 4, 2]

    An accumulator builds its list by prepending, which is quick but reverses the order. Return Enum.reverse(acc) from the base case to get [2, 4, 6], one more pass over the finished list.

The stack is memory, and a process can run out of it

The body version reached ten million waiting calls without a stack overflow, because a BEAM process has no fixed stack size. The Efficiency Guide describes the first memory of a new process as 233 words “for the heap area (which includes the stack)”, and the stack grows into that memory as the heap does. So the limit is memory: the computer’s, or a limit set on the process. Process.flag(:max_heap_size, …) sets one, in words, and the erlang documentation says it counts “all generational heaps, the process stack” and the extra memory a garbage collection needs. A process that goes over is killed. limit.exs adds up 1 to n in processes with such a cap:

limit.exs Elixir · limit.exs
# Adds up 1 to n in a process whose memory is capped, three ways: body recursion,
# tail recursion and Enum.reduce/3 over a range.
defmodule Sum do
  def body(0), do: 0
  def body(n), do: n + body(n - 1)

  def tail(n), do: tail(n, 0)
  defp tail(0, acc), do: acc
  defp tail(n, acc), do: tail(n - 1, acc + n)

  def reduce(n), do: Enum.reduce(1..n//1, 0, &+/2)
end

defmodule Main do
  # Runs Sum.<name>(n) in a new process whose heap, stack included, may hold at
  # most `cap` words; a process that goes over is killed.
  def capped(name, n, cap) do
    parent = self()

    {pid, ref} =
      spawn_monitor(fn ->
        Process.flag(:max_heap_size, %{size: cap, kill: true, error_logger: false})
        send(parent, {:sum, apply(Sum, name, [n])})
      end)

    receive do
      {:DOWN, ^ref, :process, ^pid, :normal} ->
        receive do
          {:sum, sum} -> "returned #{sum}"
        end

      {:DOWN, ^ref, :process, ^pid, reason} ->
        "stopped with reason #{inspect(reason)}"
    end
  end

  def run do
    for n <- [100_000, 1_000_000], name <- [:body, :tail, :reduce] do
      IO.puts("cap 1000000, n = #{n}, #{name}: #{capped(name, n, 1_000_000)}")
    end

    for cap <- [2_100_000, 4_000_000] do
      IO.puts("cap #{cap}, n = 1000000, body: #{capped(:body, 1_000_000, cap)}")
    end
  end
end

Main.run()
Three sums in a process capped at 1,000,000 words, then two larger caps Python · limit_session.py
from session import run

run("""
elixir limit.exs
""")

Output

$ elixir limit.exs
cap 1000000, n = 100000, body: returned 5000050000
cap 1000000, n = 100000, tail: returned 5000050000
cap 1000000, n = 100000, reduce: returned 5000050000
cap 1000000, n = 1000000, body: stopped with reason :killed
cap 1000000, n = 1000000, tail: returned 500000500000
cap 1000000, n = 1000000, reduce: returned 500000500000
cap 2100000, n = 1000000, body: stopped with reason :killed
cap 4000000, n = 1000000, body: returned 500000500000

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

Under a cap of 1,000,000 words, all three versions returned 5,000,050,000 for n = 100,000, when the body version’s stack held about 200,000 words. For n = 1,000,000 the body version was killed: its stack would have needed 2,000,019 words. The tail version and Enum.reduce/3 over the range returned 500,000,500,000 in a few words of stack.

The last two lines show how the cap counts. A cap of 2,100,000 words, just above the stack’s 2,000,019, still killed the body version, and 4,000,000 was enough. The check happens when the process collects garbage, and it counts the room the collection needs as well as the heap and the stack it already has, so a cap must leave room for well over the stack itself.

Without a cap

Most processes have no cap, and then a deep body recursion simply takes the memory it needs: 16 MB of stack for a million numbers, 160 MB for ten million.

Checkpoint

Check: The stack as part of a process's memory

  1. One question In limit.exs, under the cap of 1,000,000 words, which call was killed?

    Choose one answer.

    Show the answer: One question

    Answer: Sum.body(1_000_000)

    max_heap_size counts the stack as part of the process's memory. Only the body version's stack grows with n, by 2 words a call, so only it went over the cap, at n = 1,000,000.

When the tail call matters, and when it does not

The measured stacks make tail recursion look always better, but the Efficiency Guide says otherwise for functions that build lists: in modern versions of Erlang, “there is usually not much difference in performance” between a body-recursive list function and a tail-recursive one that reverses the list at the end. The body version keeps a frame for each item; the tail version builds the list backwards and then again in the reverse. So the guide’s advice is to write the clearer one, and “In the time-critical parts of your code, measure before rewriting your code.” For a function that returns one value, such as a sum or a count, it draws the line the other way: a tail-recursive function that builds no list “runs in constant space”, as depth.exs showed.

One case leaves no choice. A process that waits for messages runs a loop that calls itself after each message, and the guide is direct: “The main (outer) loop for a process must be tail-recursive.” The easy way to break that rule by accident is try. loop.exs runs three versions of a counter process and asks each for its stack size:

loop.exs Elixir · loop.exs
# Three versions of a counter process that adds up the numbers it is sent. After
# a number of messages, each one is asked for the size of its stack, in words.
defmodule Counter do
  # The recursive call is the last thing the loop does: a tail call.
  def plain(count) do
    receive do
      {:add, n} -> plain(count + n)
      {:stack_size, from} -> report(from, &plain/1, count)
    end
  end

  # The same loop inside try, so that a bad message such as {:add, "5"} cannot
  # stop it. try has to stay ready to rescue an error raised by the call inside
  # it, so the recursive call is no longer a tail call.
  def guarded(count) do
    try do
      receive do
        {:add, n} -> guarded(count + n)
        {:stack_size, from} -> report(from, &guarded/1, count)
      end
    rescue
      ArithmeticError -> guarded(count)
    end
  end

  # The fix: rescue only the addition, then make the call outside try.
  def fixed(count) do
    receive do
      {:add, n} -> fixed(add(count, n))
      {:stack_size, from} -> report(from, &fixed/1, count)
    end
  end

  defp add(count, n) do
    count + n
  rescue
    ArithmeticError -> count
  end

  defp report(from, loop, count) do
    send(from, Process.info(self(), :stack_size))
    loop.(count)
  end
end

defmodule Main do
  # Starts a counter, sends it `messages` numbers, then asks for its stack size.
  def stack_after(loop, messages) do
    pid = spawn(fn -> loop.(0) end)
    for _ <- 1..messages, do: send(pid, {:add, 1})
    send(pid, {:stack_size, self()})

    receive do
      {:stack_size, words} ->
        Process.exit(pid, :kill)
        words
    end
  end

  def run do
    loops = [plain: &Counter.plain/1, guarded: &Counter.guarded/1, fixed: &Counter.fixed/1]

    for {name, loop} <- loops, messages <- [1_000, 100_000] do
      IO.puts("#{name}, after #{messages} messages: #{stack_after(loop, messages)} words of stack")
    end
  end
end

Main.run()
Three counter loops after 1,000 and 100,000 messages Python · loop_session.py
from session import run

run("""
elixir loop.exs
""")

Output

$ elixir loop.exs
plain, after 1000 messages: 6 words of stack
plain, after 100000 messages: 6 words of stack
guarded, after 1000 messages: 3009 words of stack
guarded, after 100000 messages: 300009 words of stack
fixed, after 1000 messages: 6 words of stack
fixed, after 100000 messages: 6 words of stack

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

The plain loop held 6 words of stack after 100,000 messages. guarded/1, the same loop wrapped in try so that a bad message cannot stop it, grew by 3 words a message, to 300,009 words, and it would keep growing for as long as it runs. The documentation of try/1 gives the reason: “Calls inside try/1 are not tail recursive since the VM needs to keep the stacktrace in case an exception happens.” fixed/1 rescues only the addition, in a function of its own, and makes the recursive call outside try: 6 words again.

Answer the two questions about a function of your own to see which way of writing it fits:

Interactive · answer and compare Body recursion, tail recursion or an Enum function? Answer the two questions about your function. The table marks which ways of writing it fit, and why.
What does your function do?
How long can its input get?

Answer the questions above: the table then marks what fits your case and says why the rest does not.

Three ways to write a recursive function, against four things this lesson measured
Written as Builds a list Returns one value Millions deep A process's loop
Body recursion Shape: price + total(rest) Good usually as fast as tail recursion with its reverse, and the clearest to read Mixed 2 words of stack per waiting call until the base case Poor 20,000,019 words at 10 million; killed under a 1,000,000-word cap Poor a loop that never returns grows its stack for as long as it runs
Tail recursion Shape: total(rest, acc + price) Mixed builds the list backwards, so it needs Enum.reverse/1 at the end Good 19 words of stack at every n in depth.exs Good returned at 1,000,000 under the 1,000,000-word cap Good the shape a loop must have: 6 words after 100,000 messages
An Enum function Shape: Enum.sum(prices), Enum.map(list, f) Good Enum.map/2 and Enum.filter/2 build the list for you Good Enum.sum/1 and Enum.reduce/3 hold no waiting calls of yours Good Enum.reduce/3 over 1..1_000_000 returned under the cap Poor not for receive loops: a loop waits for messages, it walks no collection
How the answers decide
  • “What does your function do?” Never returns: it is a process's receive loop: rules out Body recursion and An Enum function. A process's loop calls itself after every message for as long as it runs, so only a tail call keeps its stack from growing without end.
  • “What does your function do?” Builds a new list from a list: adds a caveat to Tail recursion. For list builders the Efficiency Guide finds little difference in speed between the two kinds of recursion: write the clearer one, or let Enum build it.
  • “What does your function do?” Returns one value, such as a sum or a count and “How long can its input get?” Millions, or no known limit: rules out Body recursion. Body recursion keeps a frame for every item until the end: 2 words each, 160 MB at 10 million, and a process with a heap cap is killed.
  • “What does your function do?” Returns one value, such as a sum or a count and “How long can its input get?” Thousands of items at most: adds a caveat to Body recursion. Thousands of waiting calls cost tens of kilobytes of stack, 2 words each, so the clearer version is fine; switch to an accumulator when the input can grow.

In short: let Enum build lists and compute sums when it can, write whichever recursion is clearer for a list builder on input of ordinary size, use an accumulator for a value computed over input that can grow large, and always make a process’s loop a tail call.

Checkpoint

Check: When a tail call matters

  1. Question 1 of 2 In loop.exs, guarded/1 grew by 3 words of stack for every message, while plain/1 stayed at 6 words. Why?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Calls inside try are not tail calls, because the VM keeps the stack trace in case the call raises

    A recursive call in the do block of a try is never in tail position: try must stay ready to rescue anything the call raises. fixed/1 rescues only the addition and calls itself outside try, which brought it back to 6 words.

  2. Question 2 of 2 Which of these most needs rewriting with a tail call?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: A process's receive loop that calls itself inside try after every message

    A tail call matters where the recursion is long or never ends. A receive loop never ends, so a non-tail loop grows without limit; short list builders and small sums cost little either way.

Measure fairly before you rewrite

The guide’s “measure before rewriting” needs a measurement that measures the code. The quick way is to time each version with :timer.tc/1 in the process you are already in. naive_bench.exs does that for the tail and the body version of Double, then does the same again, all in one process, on the same 1,000,000 numbers:

double.ex: three ways to double every number Elixir · double.ex
# Three ways to double every number of a list, compared by the two benchmarks.
defmodule Double do
  # Body recursion: each new cell waits for the doubled rest of the list.
  def body([]), do: []
  def body([n | rest]), do: [n * 2 | body(rest)]

  # Tail recursion: build the list back to front, then reverse it once.
  def tail(list), do: list |> tail([]) |> Enum.reverse()

  defp tail([], acc), do: acc
  defp tail([n | rest], acc), do: tail(rest, [n * 2 | acc])

  # What you would usually write.
  def enum(list), do: Enum.map(list, &(&1 * 2))
end
naive_bench.exs Elixir · naive_bench.exs
# The quick way to compare Double.tail/1 and Double.body/1: time each one once,
# then both again, in the script's own process, on the same 1,000,000 numbers.
Code.require_file("double.ex", __DIR__)

defmodule Main do
  def run do
    numbers = Enum.to_list(1..1_000_000)

    for round <- [1, 2] do
      [tail, body] = for name <- [:tail, :body], do: time(name, numbers)
      IO.puts("round #{round}: tail #{ms(tail)}, body #{ms(body)}; #{verdict(tail, body)}")
    end
  end

  # One measurement, in microseconds.
  defp time(name, numbers) do
    {micros, _doubled} = :timer.tc(fn -> apply(Double, name, [numbers]) end)
    micros
  end

  defp verdict(tail, body) when tail > body, do: "body looks #{ratio(tail, body)} times as fast"
  defp verdict(tail, body), do: "tail looks #{ratio(body, tail)} times as fast"

  defp ratio(a, b), do: :erlang.float_to_binary(a / b, decimals: 1)
  defp ms(micros), do: :erlang.float_to_binary(micros / 1000, decimals: 1) <> " ms"
end

Main.run()
Two rounds of quick timing in one process Python · naive_session.py
from session import run

run("""
elixir naive_bench.exs
""")

Output

$ elixir naive_bench.exs
round 1: tail 67.7 ms, body 16.4 ms; body looks 4.1 times as fast
round 2: tail 61.9 ms, body 14.3 ms; body looks 4.3 times as fast

This output changes from run to run: the times depend on the computer and on what else it is doing at that moment

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

Both rounds tell the same story: the body version about four times as fast as the tail version, 16.4 ms against 67.7 ms in round 1. The fair measurement below shows that the story is wrong. All four runs shared one process, whose heap already held the million numbers and kept what each run left behind for the next, so each time depended on the state of that heap as much as on the code. The Benchmarking page of the Efficiency Guide warns that when tests share a process, “the later tests start out with larger heap sizes and therefore probably do fewer garbage collections”. Its advice is to run each test in “its own, newly created Erlang process”, several times, and to note the shortest time. fair_bench.exs does that, and reports the median as well, the typical run, which one slow run cannot move far:

Interactive · stage by stage One fair comparison, step by step Step through what fair_bench.exs does for one version of Double. Every run starts in a process of its own.
One fair comparison, step by step, step 6 of 6. Showing Start a new process, Copy the numbers in, Time one call, Send the time back, Repeat 21 times, Median and fastest. In focus: Median and fastest.next runStart a new processa heap of its ownCopy the numbers inbefore the clock startsTime one call:timer.tc/1 around one callSend the time backthen the process endsRepeat 21 timesand sort the 21 timesMedian and fastestwith versions and machine21 sorted times

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

Every step as text
  1. A new process. Each run starts a process of its own with :erlang.spawn_opt/2. It owns a heap that no earlier run has grown or filled with garbage, as the Efficiency Guide asks: each test in its own, newly created process.
  2. The input. The 1,000,000 numbers are copied into the new process when it starts, so building and copying them is not part of the time.
  3. The measurement. :timer.tc/1 runs one call of the version being measured and returns how long it took in microseconds, wall-clock time.
  4. The result. The process sends its time to the parent and ends, and its memory goes with it. Nothing it did can change the next run.
  5. Many runs. One run says little, because times vary with what else the computer is doing. The parent repeats the run 21 times and sorts the times.
  6. Two numbers. It reports the median, the 11th of 21 times, which one slow run cannot move far, and the fastest, the best the code did, which the guide suggests noting. The printout also names the versions and the machine.

fair_bench.exs Elixir · fair_bench.exs
# A fairer comparison: every run in a new process, 21 runs of each version, and
# the median and the fastest run reported; then the same with each process given
# a heap of 4,000,000 words from the start, so that it never has to grow.
Code.require_file("double.ex", __DIR__)

defmodule Bench do
  @runs 21

  # One run: a new process times one version on the numbers and sends the time
  # back. The numbers are copied into the process when it starts, before the clock.
  def once(name, numbers, spawn_options) do
    parent = self()

    {pid, ref} =
      :erlang.spawn_opt(
        fn ->
          {micros, _doubled} = :timer.tc(fn -> apply(Double, name, [numbers]) end)
          send(parent, {self(), micros})
        end,
        [:monitor | spawn_options]
      )

    receive do
      {^pid, micros} ->
        receive do
          {:DOWN, ^ref, :process, ^pid, _reason} -> micros
        end
    end
  end

  # The median and the fastest of @runs runs, in microseconds.
  def summary(name, numbers, spawn_options) do
    times = Enum.sort(for _ <- 1..@runs, do: once(name, numbers, spawn_options))
    {Enum.at(times, div(@runs, 2)), hd(times)}
  end
end

defmodule Main do
  def run do
    otp = File.read!(Path.join([:code.root_dir(), "releases", System.otp_release(), "OTP_VERSION"]))

    IO.puts(
      "Elixir #{System.version()} on Erlang/OTP #{String.trim(otp)}, " <>
        "#{:erlang.system_info(:system_architecture)}, #{System.schedulers_online()} schedulers"
    )

    IO.puts("21 runs of each, each run in a new process: median (fastest)")
    IO.puts("version          default heap        heap of 4,000,000 words")
    numbers = Enum.to_list(1..1_000_000)

    for {name, label} <- [body: "body", tail: "tail + reverse", enum: "Enum.map"] do
      {median, fastest} = Bench.summary(name, numbers, [])
      {big_median, big_fastest} = Bench.summary(name, numbers, min_heap_size: 4_000_000)

      IO.puts(
        String.pad_trailing(label, 17) <>
          String.pad_trailing("#{ms(median)} (#{ms(fastest)})", 20) <>
          "#{ms(big_median)} (#{ms(big_fastest)})"
      )
    end
  end

  defp ms(micros), do: :erlang.float_to_binary(micros / 1000, decimals: 1) <> " ms"
end

Main.run()
21 runs of each version, each in a new process Python · fair_session.py
from session import run

run("""
/usr/sbin/sysctl -n machdep.cpu.brand_string
elixir fair_bench.exs
""")

Output

$ /usr/sbin/sysctl -n machdep.cpu.brand_string
Apple M5 Pro
$ elixir fair_bench.exs
Elixir 1.20.4 on Erlang/OTP 29.1.1, aarch64-apple-darwin, 18 schedulers
21 runs of each, each run in a new process: median (fastest)
version          default heap        heap of 4,000,000 words
body             24.9 ms (21.5 ms)   8.8 ms (8.2 ms)
tail + reverse   28.8 ms (25.9 ms)   7.3 ms (6.2 ms)
Enum.map         35.8 ms (32.7 ms)   11.1 ms (10.5 ms)

This output changes from run to run: the times depend on the computer and on what else it is doing, and the first line names the processor that ran it

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

With every run in a new process, the medians were 24.9 ms for the body version and 28.8 ms for the tail version: the tail version about 16 % slower, not four times. The second column gives each process a heap of 4,000,000 words from the start, big enough that it hardly collects garbage during the run; the erlang documentation says a larger minimum heap “can speed up some processes because less garbage collection is done”. Every version became three to four times as fast, and the order changed: the tail version, at 7.3 ms, was now the fastest.

A measuring instrument, not a setting to copy

The documentation of spawn_opt/4 says a minimum heap set too high “can waste memory and slow down the system”, and calls the option useful only for performance tuning. Here it shows where the time goes; it is not a fix to apply to every process.

So most of the time in the first column was garbage collection, and the style of the recursion moved the result by a few milliseconds either way. Enum.map/2, which calls the function it is given once for every number, was the slowest in both columns, at 35.8 ms and 11.1 ms. That is the guide’s advice in numbers: write the clearer version, and if a function is slow, measure in new processes before rewriting it.

Key idea

Compare versions only from runs that each start in a new process, many of them, summed up by the median and the fastest run, with the versions and the machine stated. A single run in a process that has already done work measures its heap, not your code.

Checkpoint

Check: Measuring two versions fairly

  1. Question 1 of 2 naive_bench.exs made the body version look about four times as fast as the tail version; with a new process for every run, the medians were 24.9 ms and 28.8 ms. Why was the quick measurement so far off?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: All its runs shared one process, so each time depended on the state of that heap and the garbage earlier runs left

    Timing in one long-lived process measures how its heap happens to be, which every earlier run changes. A fair comparison starts every run in a new process and repeats it many times.

  2. Question 2 of 2 Which steps of fair_bench.exs make its comparison fair? Choose all that apply.

    Choose every answer that is right.

    Show the answer: Question 2 of 2

    Answer:

    • The output names the versions and the machine
    • Every run starts in a new process
    • Each version runs 21 times, summed up by the median and the fastest

    New processes, many runs, the median with the fastest, and the setting stated are what make two times comparable. The large heap in the second column is a way to see where the time goes, not a setting to copy.

Practise: walk a tree

A binary tree here is either nil, the empty tree, or a tuple {left, value, right} whose left and right are trees. Write three functions for it: depth/1, the number of levels, flatten/1, the values from left to right, and sum/1, which must be tail-recursive. A tree has two subtrees, so a tail-recursive walk cannot just call itself on “the rest”: sum/2 keeps a list of the subtrees still to visit, an explicit stack, and the total so far. Copy the two files into an empty folder:

practice/tree.ex: the starter Elixir · practice/tree.ex
# A binary tree is either nil (empty) or a tuple {left, value, right}, where left
# and right are trees. Write the three functions; each starter returns a
# placeholder.
defmodule Tree do
  # The number of levels: 0 for nil, 1 for a tree of one node.
  def depth(_tree), do: 0

  # The values from left to right: the left subtree's, the node's own, then the
  # right subtree's.
  def flatten(_tree), do: []

  # The sum of the values, tail-recursive: sum/2 keeps a list of the subtrees
  # still to visit, and the total so far.
  def sum(tree), do: sum([tree], 0)

  def sum(_pending, total), do: total
end
practice/tree_test.exs: the tests Elixir · practice/tree_test.exs
Code.require_file("tree.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule TreeTest do
  use ExUnit.Case

  #        4
  #      /   \
  #     2     5
  #    / \
  #   1   3
  @tree {{{nil, 1, nil}, 2, {nil, 3, nil}}, 4, {nil, 5, nil}}

  # A tree that is one long chain: each node's right subtree is the next node.
  defp chain(n), do: Enum.reduce(1..n//1, nil, fn value, rest -> {nil, value, rest} end)

  test "an empty tree" do
    assert Tree.depth(nil) == 0
    assert Tree.flatten(nil) == []
    assert Tree.sum(nil) == 0
  end

  test "depth counts the levels of the longest path" do
    assert Tree.depth({nil, 7, nil}) == 1
    assert Tree.depth(@tree) == 3
  end

  test "flatten lists the values from left to right" do
    assert Tree.flatten(@tree) == [1, 2, 3, 4, 5]
  end

  test "sum adds every value" do
    assert Tree.sum(@tree) == 15
  end

  test "sum/2 takes the subtrees still to visit and the total so far" do
    assert Tree.sum([], 7) == 7
    assert Tree.sum([nil, {nil, 5, nil}], 1) == 6
    assert Tree.sum([{nil, 2, nil}, @tree], 0) == 17
  end

  test "a chain of 1,000,000 nodes" do
    tree = chain(1_000_000)
    assert Tree.depth(tree) == 1_000_000
    assert Tree.sum(tree) == 500_000_500_000
    assert Tree.flatten(tree) |> Enum.take(3) == [1_000_000, 999_999, 999_998]
  end
end

Run elixir tree_test.exs. The starter returns placeholders, so only the test of the empty tree passes:

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

# The tests against the starter, whose functions return placeholders.
run("""
cd practice
!elixir tree_test.exs
""")

Output

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

.

  1) test depth counts the levels of the longest path (TreeTest)
     tree_test.exs:23
     Assertion with == failed
     code:  assert Tree.depth({nil, 7, nil}) == 1
     left:  0
     right: 1
     stacktrace:
       tree_test.exs:24: (test)



  2) test flatten lists the values from left to right (TreeTest)
     tree_test.exs:28
     Assertion with == failed
     code:  assert Tree.flatten(@tree) == [1, 2, 3, 4, 5]
     left:  []
     right: [1, 2, 3, 4, 5]
     stacktrace:
       tree_test.exs:29: (test)



  3) test sum adds every value (TreeTest)
     tree_test.exs:32
     Assertion with == failed
     code:  assert Tree.sum(@tree) == 15
     left:  0
     right: 15
     stacktrace:
       tree_test.exs:33: (test)



  4) test sum/2 takes the subtrees still to visit and the total so far (TreeTest)
     tree_test.exs:36
     Assertion with == failed
     code:  assert Tree.sum([nil, {nil, 5, nil}], 1) == 6
     left:  1
     right: 6
     stacktrace:
       tree_test.exs:38: (test)



  5) test a chain of 1,000,000 nodes (TreeTest)
     tree_test.exs:42
     Assertion with == failed
     code:  assert Tree.depth(tree) == 1_000_000
     left:  0
     right: 1000000
     stacktrace:
       tree_test.exs:44: (test)


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

Result: 1/6 passed
Failed: 5 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

depth/1 is body recursion: one more than the deeper of the two subtrees, with nil as the base case at 0. For sum/2, take the first tree off the list of pending trees: nil adds nothing, and a node adds its value and puts both of its subtrees on the front of the list, so the call to sum/2 is the last thing every clause does. For flatten/1, avoid flatten(left) ++ [value | flatten(right)], whose ++ copies the left part at every level; pass an accumulator instead, flatten the right subtree into it first and prepend the value, so the list comes out in order without a reverse. The last test walks a chain of 1,000,000 nodes. A finished version passes all six tests:

The tests against a finished Tree module Python · finished_session.py
from session import run

# The same tests against a finished Tree module.
run("""
cd finished
cp ../practice/tree_test.exs .
elixir tree_test.exs
""")

Output

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

......
Finished in 0.1 seconds (0.03s on load, 0.00s async, 0.1s 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

Then test yourself: the self-check asks about the five ideas of this lesson one question at a time, the weakest idea first, and explains each answer as soon as you give it:

Practice that adapts to you

Practise this lesson

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

  1. Question 1 of 14 What does the base case of a recursive function do?

    Choose one answer.

    Show the answer: Question 1 of 14

    Answer: Returns a result for the simplest input, without calling the function again

    A recursive function needs a clause for the input its calls finally reach, such as the empty list, that returns without recursing. Every other clause does one step and calls the function on a smaller input.

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

    Read the code, then choose one answer.

    defmodule Halve do
      def steps(1), do: 0
      def steps(n), do: 1 + steps(div(n, 2))
    end
    
    Halve.steps(8)
    Show the answer: Question 2 of 14

    Answer: 3

    steps(8) is 1 + steps(4), which is 1 + steps(2), which is 1 + steps(1), and steps(1) returns 0. The waiting additions give 1 + 1 + 1 + 0 = 3.

  3. Question 3 of 14 By mistake, a clause reads def total([price | rest]), do: price + total([price | rest]). What happens when it is called on a bill of two prices?

    Choose one answer.

    Show the answer: Question 3 of 14

    Answer: It never returns, and its stack grows until the process runs out of memory or a cap kills it

    Each recursive call must move towards the base case. Calling total/1 on the whole list instead of rest repeats the same call forever, and since it is body recursion, the stack grows with every repetition.

  4. Question 4 of 14 Why does the BEAM need no new frame for a tail call?

    Choose one answer.

    Show the answer: Question 4 of 14

    Answer: Nothing is left to do after it returns, so the caller's frame can be reused

    A frame keeps what a call still has to do. A tail call leaves nothing, so its frame is replaced instead of kept, and a function that calls itself only that way runs in a fixed amount of stack.

  5. Question 5 of 14 What does the last line return?

    Read the code, then choose one answer.

    defmodule Twice do
      def all(list), do: all(list, [])
    
      defp all([], acc), do: acc
      defp all([x | rest], acc), do: all(rest, [x * 2 | acc])
    end
    
    Twice.all([1, 2, 3])
    Show the answer: Question 5 of 14

    Answer: [6, 4, 2]

    Prepending to an accumulator reverses the order, so a tail-recursive list builder returns Enum.reverse(acc) from its base case. Without it the result is [6, 4, 2].

  6. Question 6 of 14 From the depth.exs measurement, 2n + 19 words, how many words of stack does Sum.body(5_000) hold at its base case?

    Type a number.

    A hint

    Each waiting call kept 2 words, and 19 words were there before the recursion started.

    Show the answer: Question 6 of 14

    Answer: 10019 words

    2 × 5,000 + 19 = 10,019 words, about 80 KB with 8-byte words. The tail version would hold 19 words, as it did for every n in depth.exs.

  7. Question 7 of 14 In a process without a max_heap_size cap, what limits how deep a body recursion can go?

    Choose one answer.

    Show the answer: Question 7 of 14

    Answer: Memory, because the stack is part of the process's memory and grows with its heap

    A BEAM process has no fixed stack size: the stack lives in the process's memory and grows as needed. Memory is the limit, or a max_heap_size cap where one is set.

  8. Question 8 of 14 Sum.body(1_000_000) needs 2,000,019 words of stack. Under a cap of 2,100,000 words it was killed, and under 4,000,000 it returned. Why was 2,100,000 not enough?

    Choose one answer.

    Show the answer: Question 8 of 14

    Answer: The cap also counts the room a garbage collection needs while it runs, on top of the heap and the stack

    The cap is checked when the process collects garbage and counts its heaps, its stack and the room the collection needs at that moment. So a process is killed well before its stack alone reaches the cap.

  9. Question 9 of 14 What does the Erlang Efficiency Guide say about body-recursive and tail-recursive functions that build lists?

    Choose one answer.

    Show the answer: Question 9 of 14

    Answer: There is usually not much difference in speed, so write the clearer one and measure before rewriting

    For list builders the two styles cost about the same in modern Erlang, so clarity decides. The line is drawn for functions that return one value, where only the tail-recursive version runs in constant space.

  10. Question 10 of 14 A process runs this loop. How does its stack change as {:add, n} messages arrive?

    Read the code, then choose one answer.

    def loop(total) do
      receive do
        {:add, n} ->
          new_total = loop(total + n)
          IO.puts("stopped at #{new_total}")
          new_total
    
        :stop ->
          total
      end
    end
    Show the answer: Question 10 of 14

    Answer: It grows with every message, because IO.puts still has to run after each call returns

    Move the logging out of the loop, or into the :stop clause, and call loop(total + n) as the last thing the clause does. Then each message reuses the frame.

  11. Question 11 of 14 A receive loop must survive a step that can raise. Where can the recursive call go and still be a tail call?

    Choose one answer.

    Show the answer: Question 11 of 14

    Answer: After try has finished, or as the last call inside try's else clause

    Rescue only the risky step, in a function or a try of its own, and make the recursive call after it, as fixed/1 does. A try with an else clause allows the same: the call as the final expression of else.

  12. Question 12 of 14 Why are times measured one after another in one long-lived process unreliable?

    Choose one answer.

    Show the answer: Question 12 of 14

    Answer: Each run starts from the heap the runs before it grew and filled with garbage

    Garbage collection is per process, and its cost depends on how big the heap already is. Runs that share a process share that history, so their times cannot be compared.

  13. Question 13 of 14 fair_bench.exs sorts the 21 times of a version. Which position in the sorted list is the median it reports?

    Type a number.

    A hint

    The median has as many times below it as above it.

    Show the answer: Question 13 of 14

    Answer: 11 th of 21

    With 21 sorted times, 10 lie below the 11th and 10 above it, so the 11th is the median; the program reads it as Enum.at(times, div(21, 2)), position 10 counting from 0.

  14. Question 14 of 14 With each process given a heap of 4,000,000 words from the start, every version of Double ran several times faster. What should you conclude?

    Choose one answer.

    Show the answer: Question 14 of 14

    Answer: Most of the default-heap time went into growing the heap and collecting garbage, not into the recursion style

    A large first heap removes most garbage collection from the run, so the drop in time shows how much of it was garbage collection. That is an argument about where to look before rewriting, not a setting to copy.

Interview questions

Warm-up: what is a tail call, and why does it matter in Elixir?

A tail call sits in the last position of a function: once it returns, the caller has nothing left to do and passes the result on unchanged. The BEAM reuses the caller’s stack frame for it, so a function that calls itself only in tail position, usually with an accumulator, runs in a stack of fixed size: 19 words for any n in this lesson, against 2 words per waiting call for body recursion. It matters most in two places. A process’s receive loop never returns, so it must be a tail call or its stack grows with every message, and a call in the do block of a try never is one. A function that computes one value over a long input stays small too. For list builders the Efficiency Guide finds little difference between the two styles, so write the clearer one and measure before rewriting.

Two questions from code that loops and code that was measured:

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

  1. Mid to senior

    A counter process wraps its receive loop in try/rescue so that one malformed message cannot crash it. It handles about 1,000 messages a second, and its memory grows by about 2 GB a day until the node is restarted. Explain the cause, fix the loop, and say how you would confirm the fix.

    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 timed a body-recursive and a tail-recursive version of a list transformation once each in IEx with :timer.tc/1, found the tail version twice as fast, and proposes rewriting every body-recursive function in the codebase. What do you check before agreeing, and how would you measure properly?

    Model answer, Pro. First step and follow-ups

Key takeaways

  • A recursive function handles the simplest input in a base case and calls itself on a smaller input in its other clauses; without a base case the calls reach an input no clause matches.
  • A tail call is the last thing a function does, so the BEAM reuses its frame: a tail-recursive sum kept 19 words of stack for any n, while body recursion kept 2 words per waiting call, 20,000,019 words for ten million.
  • An accumulator carries the result through the calls and builds lists backwards, so reverse once, in the clause that returns.
  • A process’s stack is part of its memory, so a deep body recursion is limited only by memory, or killed by a max_heap_size cap that counts the heap, the stack and the room a garbage collection needs.
  • For list builders the two styles cost about the same, but a process’s receive loop must be tail-recursive, and a call in the do block of a try never is.
  • Measure with a new process for every run, many runs, the median and the fastest; timings from one process that has already done work measure its heap, not the code.

Terms in this lesson

accumulator
An extra argument that carries the result built so far through each recursive call, such as a running total. The base case returns it; a list built in one by prepending comes out in reverse order.
base case
The clause of a recursive function that returns without calling the function again, such as total([]) returning 0. Every chain of recursive calls has to reach one, or it fails with FunctionClauseError or never ends.
body recursion
Recursion with work left to do after the recursive call returns, as in price + total(rest). Each waiting call keeps a frame on the process's stack until the calls below it have returned.
tail call
A call that is the last thing a function does, so nothing is left to do when it returns. The BEAM reuses the caller's stack frame for it, so a function that calls itself only this way runs in a stack of fixed size.

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.