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

Pipes, dbg/2 and debugging in IEx

Write Elixir pipelines with |>, then/2 and tap/2, print each step with dbg/2 and IO.inspect/2, and stop running code in IEx with breakpoints and IEx.pry.

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

What you will learn

  • Rewrite nested calls as a pipeline with |>, then/2 and tap/2
  • Inspect the value after each step of a pipeline with IO.inspect/2 and dbg/2
  • Remove the colour codes from dbg/2 output in logs with syntax_colors
  • Debug a running function in IEx with break! or IEx.pry/0, stepping through it with next
  • Choose a debugging tool by whether the code can change and whether it must stop

Before you start

What you’ll learn · Before you start

What you will learn

  • Rewrite nested calls as a pipeline with |>, then/2 and tap/2
  • Inspect the value after each step of a pipeline with IO.inspect/2 and dbg/2
  • Remove the colour codes from dbg/2 output in logs with syntax_colors
  • Debug a running function in IEx with break! or IEx.pry/0, stepping through it with next
  • Choose a debugging tool by whether the code can change and whether it must stop

Before you start

On this page

Enum.uniq(Enum.map(String.split(String.downcase(String.trim(text)), ","), &String.trim/1)) cleans a list of cities in five steps, and it reads from the inside out: the first step is the call in the middle. The pipe operator: |>, which passes the value on its left as the first argument of the call on its right: text |> String.trim() is String.trim(text), so nested calls can be written as steps from top to bottom. |> writes the same five steps from top to bottom, one a line, and once every step is a line of its own, Elixir can print each step’s value for you.

Below, you will write pipelines with |>, then/2 and tap/2, print values along the way with IO.inspect/2 and dbg/2, keep colour codes out of your logs, stop a running function in IEx and step through it line by line, and pick the right tool for what you need to see. The sessions come from Elixir 1.20.4, built for Erlang/OTP 29.1.1.

The pipe passes each result on as the first argument

a |> f(b) is f(a, b): the Kernel documentation says the pipe puts the value on its left “as the first argument to the function call on the right-hand side”. This script cleans one line of city names both ways:

pipe.exs: nested calls and a pipeline Elixir · pipe.exs
# The same five steps, written inside out and as a pipeline.
text = " Pune,Agra , pune "

nested = Enum.uniq(Enum.map(String.split(String.downcase(String.trim(text)), ","), &String.trim/1))
IO.inspect(nested, label: "nested")

piped =
  text
  |> String.trim()
  |> String.downcase()
  |> String.split(",")
  |> Enum.map(&String.trim/1)
  |> Enum.uniq()

IO.inspect(piped, label: "piped")
Running pipe.exs Python · pipe_session.py
from session import run

run("""
elixir pipe.exs
""")

Output

$ elixir pipe.exs
nested: ["pune", "agra"]
piped: ["pune", "agra"]

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

Both gave ["pune", "agra"]. The pipeline lists the steps in the order they happen, and an argument such as "," stays next to the function it belongs to, instead of ending up three closing brackets later. Step through what each line hands to the next:

Interactive · stage by stage text |> String.trim() |> …: what each step passes on Step through the pipeline. Each stage is one call after a |>; the value under it is what that call returned and handed on.
text |> String.trim() |> …: what each step passes on, step 6 of 6. Showing text, String.trim(), String.downcase(), String.split(","), Enum.map(&String.trim/1), Enum.uniq(). In focus: Enum.uniq().text" Pune,Agra , pune "String.trim()"Pune,Agra , pune"String.downcase()"pune,agra , pune"String.split(",")["pune", "agra ", " pune"]Enum.map(&String.trim/1)["pune", "agra", "pune"]Enum.uniq()["pune", "agra"]the list

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

Every step as text
  1. The value. The pipeline starts with the value on its first line, the text with spaces at both ends and two spellings of Pune.
  2. The first step. text |> String.trim() is String.trim(text): the value on the left becomes the first argument of the call on the right. The spaces at the ends go.
  3. The next step. The result goes on to the next line in the same way, String.downcase/1, so both spellings of Pune become "pune".
  4. Other arguments stay. String.split(",") already has one argument, ",". The piped value goes in front of it, so the call is String.split(value, ","), and the text becomes a list of three parts.
  5. A function as argument. Enum.map(&String.trim/1) trims each part: the list is its first argument, the capture its second. " pune" loses its space and matches "pune".
  6. The result. Enum.uniq/1 drops the repeated "pune", and the value of the whole pipeline is ["pune", "agra"], the same as the nested version gave.

Each step’s value became the first argument of the next call, ahead of the arguments written in its brackets. That is why pipelines suit Elixir’s libraries: String, Enum and Map take the data they work on as their first argument.

Piping into a function held in a name

"pune" |> shout does not compile, because the pipe always calls a function, and there is no named function shout/1. Call the value with a dot, "pune" |> shout.(), or hand it to then/2, "pune" |> then(shout), as the next section shows.

Checkpoint

Check: The pipe and the first argument

  1. Question 1 of 2 Which nested call is the same as "a,b" |> String.upcase() |> String.split(",")?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: String.split(String.upcase("a,b"), ",")

    a |> f(b) is f(a, b). Read a pipeline from the top, and turn it into nested calls from the inside out.

  2. Question 2 of 2 What does this return?

    Read the code, then choose one answer.

    [3, 1, 2] |> Enum.map(&(&1 * 10)) |> Enum.take(2)
    Show the answer: Question 2 of 2

    Answer: [30, 10]

    Each step's written arguments come after the piped value: Enum.take(list, 2).

then/2 and tap/2 handle the other positions and side effects

Two Kernel functions fill the gaps of the pipe. then/2 “pipes the first argument, value, into the second argument, a function fun, and returns the result of calling fun”, so it handles a step where the value is not the first argument, or where the step is a function you hold. tap/2 calls the function too but “returns value itself”, so it suits a side effect such as a log line in the middle of a pipeline:

then_tap.exs Elixir · then_tap.exs
# then/2 for a step whose value is not the first argument; tap/2 for a side effect that keeps the value.
summary =
  [" Pune", "Agra ", " pune"]
  |> Enum.map(&String.trim/1)
  |> tap(fn names -> IO.puts("cleaned #{length(names)} names") end)
  |> Enum.uniq_by(&String.downcase/1)
  |> then(fn names -> %{cities: names, count: length(names)} end)

IO.inspect(summary)

# A function held in a name is piped into with a dot, or through then/2.
shout = fn text -> String.upcase(text) end
IO.puts("pune" |> shout.())
IO.puts("agra" |> then(shout))
Running then_tap.exs Python · then_tap_session.py
from session import run, shell

run("""
elixir then_tap.exs
""")

# Piping into the name of a function value, without a dot or then/2.
shell([
    "shout = fn text -> String.upcase(text) end",
    "\"pune\" |> shout",
    "System.halt()",
])

Output

$ elixir then_tap.exs
cleaned 3 names
%{count: 2, cities: ["Pune", "Agra"]}
PUNE
AGRA
$ 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)> shout = fn text -> String.upcase(text) end
#Function<42.130099583/1 in :erl_eval.expr/6>
iex(2)> "pune" |> shout
error: undefined function shout/1 (there is no such import)
└─ iex:2

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

iex(2)> System.halt()

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

tap/2 printed “cleaned 3 names” and passed the three names on unchanged, so Enum.uniq_by/2 still got all three and kept “Pune” and “Agra”. then/2 built a map around the list, a value no function of Enum returns, and the pipeline’s result is that map. The last two lines piped into the function held in shout, once with .() and once through then/2. Typed into IEx without either, "pune" |> shout did not compile: “undefined function shout/1”.

Key idea

|> fills the first argument; then/2 takes any function and returns what it returns, and tap/2 runs a function for its side effect and passes the value on unchanged.

Checkpoint

Check: then/2 and tap/2

  1. Question 1 of 2 What is the value of the pipeline?

    Read the code, then choose one answer.

    [1, 2, 3]
    |> tap(fn list -> IO.puts(length(list)) end)
    |> Enum.sum()
    Show the answer: Question 1 of 2

    Answer: 6

    tap/2 is for side effects: the function runs, and its result is ignored.

  2. Question 2 of 2 A step must put the piped list into a map as %{cities: list}. Which line does it?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: |> then(fn list -> %{cities: list} end)

    then/2 covers every step the first argument cannot: another position, a map or tuple to build, a function held in a name.

IO.inspect/2 with a label prints a value and returns it

The simplest way to see inside a pipeline is to print what passes through it. The debugging guide points out that IO.inspect/2 returns its argument “without affecting the behavior of the original code”, so it can sit between any two steps, and its label option names each printout:

inspect_steps.exs Elixir · inspect_steps.exs
# IO.inspect/2 prints a value with a label and returns it, so it can sit between two steps.
" Pune,Agra , pune "
|> String.trim()
|> String.split(",")
|> IO.inspect(label: "after split")
|> Enum.map(&String.trim/1)
|> IO.inspect(label: "after trim")
|> Enum.uniq()
|> IO.inspect(label: "result")
Running inspect_steps.exs Python · inspect_session.py
from session import run

run("""
elixir inspect_steps.exs
""")

Output

$ elixir inspect_steps.exs
after split: ["Pune", "Agra ", " pune"]
after trim: ["Pune", "Agra", "pune"]
result: ["Pune", "Agra", "pune"]

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

The labels show where the value changed. After String.split/2 the list held “Agra “ with a space and “ pune” with one in front; after Enum.map/2 both were trimmed. And the result kept “Pune” and “pune” side by side: this version has no String.downcase/1, which the printout makes plain at a glance. Remove the inspects once you have found what you were looking for: each one is a line of output in every run, tests included.

Checkpoint

Check: Labelled IO.inspect/2 in a pipeline

  1. One question Why can IO.inspect(label: "after split") sit between two steps of a pipeline?

    Choose one answer.

    Show the answer: One question

    Answer: It prints the value and returns it unchanged

    Because IO.inspect/2 returns its argument, it fits anywhere a value passes, with label: naming each printout.

dbg/2 prints the code and the value of every step

dbg/2 at the end of a pipeline prints each step’s code next to the value it produced, with the file and line at the top: the debugging guide says it prints values “at every step of the pipeline”. The session runs it, then shows the bytes it wrote through cat -v, which turns the escape character of each colour code into the visible ^[:

pipeline_dbg.exs Elixir · pipeline_dbg.exs
# dbg/2 at the end of a pipeline prints the code of every step and the value it gave.
" Pune,Agra , pune "
|> String.trim()
|> String.downcase()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.uniq()
|> dbg()
pipeline_plain.exs: the same pipeline with syntax_colors: [] Elixir · pipeline_plain.exs
# The same pipeline, with dbg/2 told to print no colour codes.
" Pune,Agra , pune "
|> String.trim()
|> String.downcase()
|> String.split(",")
|> Enum.map(&String.trim/1)
|> Enum.uniq()
|> dbg(syntax_colors: [])
dbg/2 in a terminal, in a pipe, and without colours Python · dbg_session.py
from session import run

# The first run as a terminal shows it; then the bytes dbg/2 wrote, through cat -v, which shows the escape
# character of each colour code as ^[; then the bytes IO.inspect/2 wrote, for comparison.
run("""
elixir pipeline_dbg.exs
elixir pipeline_dbg.exs | cat -v | head -3
NO_COLOR=1 elixir pipeline_dbg.exs | cat -v | head -3
elixir inspect_steps.exs | cat -v | head -1
elixir pipeline_plain.exs | cat -v | head -3
""")

Output

$ elixir pipeline_dbg.exs
[pipeline_dbg.exs:8: (file)]
" Pune,Agra , pune " #=> " Pune,Agra , pune "
|> String.trim() #=> "Pune,Agra , pune"
|> String.downcase() #=> "pune,agra , pune"
|> String.split(",") #=> ["pune", "agra ", " pune"]
|> Enum.map(&String.trim/1) #=> ["pune", "agra", "pune"]
|> Enum.uniq() #=> ["pune", "agra"]
$ elixir pipeline_dbg.exs | cat -v | head -3
^[[36m^[[3m[pipeline_dbg.exs:8: (file)]^[[0m
^[[0m" Pune,Agra , pune "^[[2m #=>^[[0m " Pune,Agra , pune "
^[[0m^[[2m|> ^[[0mString.trim()^[[2m #=>^[[0m "Pune,Agra , pune"
$ NO_COLOR=1 elixir pipeline_dbg.exs | cat -v | head -3
^[[36m^[[3m[pipeline_dbg.exs:8: (file)]^[[0m
^[[0m" Pune,Agra , pune "^[[2m #=>^[[0m " Pune,Agra , pune "
^[[0m^[[2m|> ^[[0mString.trim()^[[2m #=>^[[0m "Pune,Agra , pune"
$ elixir inspect_steps.exs | cat -v | head -1
after split: ["Pune", "Agra ", " pune"]
$ elixir pipeline_plain.exs | cat -v | head -3
[pipeline_plain.exs:8: (file)]
" Pune,Agra , pune " #=> " Pune,Agra , pune "
|> String.trim() #=> "Pune,Agra , pune"

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

The first run is what you read: one line per step, #=> and the value, and the two spellings of Pune merging at String.downcase(). The second shows what a file or a CI log receives from the same run: colour codes such as ^[[36m on every line, although the output went into a pipe and not to a terminal. NO_COLOR=1 changed nothing, while IO.inspect/2, run through the same cat -v, wrote no codes at all. dbg(syntax_colors: []), in the last run, wrote plain text: the Kernel documentation says the options of dbg/2 are the options of inspect/2, and :syntax_colors is the one that sets the colours of the printed code and values.

Plain dbg/2 output for logs

When the output goes to a file or a CI log, write dbg(syntax_colors: []), so that the log holds the text you would read in a terminal.

Checkpoint

Check: dbg/2 and its colour codes

  1. Question 1 of 2 What does dbg/2 print at the end of a pipeline?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: The file and line, then each step's code with the value it gave

    The debugging guide says dbg/2 prints the code and location, and the values at every step of a pipeline.

  2. Question 2 of 2 dbg/2 output in a CI log is full of sequences like ^[[36m. What turns them off?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: dbg(syntax_colors: [])

    dbg/2 takes the options of inspect/2, and syntax_colors: [] makes it print without colour codes.

IEx can stop a running function: break! and IEx.pry

Printing tells you what happened. Sometimes you need the program to stop where it is, so that you can look around. IEx can do that for code it runs, which is why the session starts IEx with the project loaded, iex -S mix. break! Report.total/1 sets a breakpoint: A place where running code stops so that you can look at its values. In IEx, break! Module.fun/arity sets one without changing the code; next runs one more line and continue lets the code finish. without changing the code; IEx.pry/0, written into the code, stops at its own line:

report/lib/report.ex Elixir · report/lib/report.ex
defmodule Report do
  def total(orders) do
    paid = Enum.filter(orders, & &1.paid)
    amounts = Enum.map(paid, & &1.amount)
    Enum.sum(amounts)
  end
end
report/lib/audit.ex Elixir · report/lib/audit.ex
defmodule Audit do
  require IEx

  def check(orders) do
    unpaid = Enum.reject(orders, & &1.paid)
    IEx.pry()
    length(unpaid)
  end
end
Stopping Report.total/1 and Audit.check/1 in IEx Python · debug_session.py
from session import run, shell

# source/1, new in Elixir 1.20, says where a function is defined. break! stops Report.total/1 without changing its code; next goes on one line at a time, continue to the end.
# Then IEx.pry/0, written into Audit.check/1, stops it at that line. Last, the same pry outside IEx.
run("""
cd report
""")
shell([
    "source Report.total/1",
    "break! Report.total/1",
    "Report.total([%{paid: true, amount: 300}, %{paid: false, amount: 50}, %{paid: true, amount: 120}])",
    "orders",
    "next",
    "paid",
    "next",
    "amounts",
    "continue",
    "breaks",
    "Audit.check([%{paid: true, amount: 300}, %{paid: false, amount: 50}])",
    "binding()",
    "continue",
    "System.halt()",
], command="iex -S mix")
run("""
mix run -e 'IO.inspect(Audit.check([%{paid: false, amount: 50}]))'
""")

Output

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

Compiling 2 files (.ex)
Generated report app
Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(1)> source Report.total/1
lib/report.ex:2
iex(2)> break! Report.total/1
1
iex(3)> Report.total([%{paid: true, amount: 300}, %{paid: false, amount: 50}, %{paid: true, amount: 120}])
Break reached: Report.total/1 (lib/report.ex:3)

    1: defmodule Report do
    2:   def total(orders) do
    3:     paid = Enum.filter(orders, & &1.paid)
    4:     amounts = Enum.map(paid, & &1.amount)
    5:     Enum.sum(amounts)
    6:   end

iex(4)> orders
[
  %{paid: true, amount: 300},
  %{paid: false, amount: 50},
  %{paid: true, amount: 120}
]
iex(5)> next
Break reached: Report.total/1 (lib/report.ex:4)

    1: defmodule Report do
    2:   def total(orders) do
    3:     paid = Enum.filter(orders, & &1.paid)
    4:     amounts = Enum.map(paid, & &1.amount)
    5:     Enum.sum(amounts)
    6:   end
    7: end

iex(6)> paid
[%{paid: true, amount: 300}, %{paid: true, amount: 120}]
iex(7)> next
Break reached: Report.total/1 (lib/report.ex:5)

    2:   def total(orders) do
    3:     paid = Enum.filter(orders, & &1.paid)
    4:     amounts = Enum.map(paid, & &1.amount)
    5:     Enum.sum(amounts)
    6:   end
    7: end

iex(8)> amounts
[300, 120]
iex(9)> continue
420
** (EXIT from #PID<0.175.0>) shell process exited with reason: normal

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(9)> breaks

 ID   Module.function/arity   Pending stops
---- ----------------------- ---------------
 1    Report.total/1          0

iex(10)> Audit.check([%{paid: true, amount: 300}, %{paid: false, amount: 50}])
Break reached: Audit.check/1 (lib/audit.ex:6)

    3:
    4:   def check(orders) do
    5:     unpaid = Enum.reject(orders, & &1.paid)
    6:     IEx.pry()
    7:     length(unpaid)
    8:   end
    9: end

iex(11)> binding()
[
  orders: [%{paid: true, amount: 300}, %{paid: false, amount: 50}],
  unpaid: [%{paid: false, amount: 50}]
]
iex(12)> continue
1
** (EXIT from #PID<0.177.0>) shell process exited with reason: normal

Interactive Elixir (1.20.4) - press Ctrl+C to exit (type h() ENTER for help)
iex(12)> System.halt()
$ mix run -e 'IO.inspect(Audit.check([%{paid: false, amount: 50}]))'
Cannot pry #PID<0.101.0> at Audit.check/1 (lib/audit.ex:6). Is an IEx shell running?
1

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

source Report.total/1, new in Elixir 1.20, printed where the function is defined. The call then stopped before line 3 with “Break reached”, and the prompt evaluated in the function’s own bindings: orders showed its argument. next ran one line and stopped before the next, so paid and then amounts could be read, and continue let it finish with 420. The ** (EXIT …) line and the new banner after it are no failure: the stop ran in the shell’s own process, and the IEx documentation says a new IEx session is started when that process ends. breaks listed breakpoint 1 with no stops left: the IEx documentation says a breakpoint stops once by default, and break! Report.total/1, 10 stops ten times. Follow the five commands:

Interactive · stage by stage break! Report.total/1: stopping a function without changing it Step through the session. Each stage is something typed at the iex> prompt, and what the paused function did.
break! Report.total/1: stopping a function without changing it, step 5 of 5. Showing break! Report.total/1, Report.total(orders), orders, next, next, continue. In focus: continue.break! Report.total/1breakpoint 1, stops: 1Report.total(orders)stops before line 3ordersthe argument it gotnextline 3 ran: paid is boundnextline 4 ran: amounts is boundcontinuethe call returns 420continue

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

Every step as text
  1. The breakpoint. break! Report.total/1 sets a breakpoint on the function from the prompt, without changing its code. IEx printed its id, 1; by default it stops once.
  2. The call stops. Calling Report.total/1 runs it in the shell's process, which stops before line 3 and prints "Break reached" with the lines around it.
  3. Looking around. While it is stopped, the prompt evaluates in the function's own bindings: orders showed the three maps it was given.
  4. One line on. next ran line 3 and stopped before line 4, so paid could be read: the two paid orders. A second next did the same for line 4 and amounts, [300, 120].
  5. To the end. continue let the function finish. It returned 420 to the call, the shell started again, and breaks showed breakpoint 1 with 0 stops left.

Audit.check/1 stopped at its IEx.pry() line, and binding() listed orders and unpaid. Both stops worked at once because the code ran in the shell’s own process; when another process reaches a breakpoint or IEx.pry/0, such as a request handled by a server started from IEx, IEx first asks whether you allow it to pry. Outside IEx, in the last command, IEx.pry/0 printed “Cannot pry … Is an IEx shell running?” and the code ran on: a forgotten pry does not stop a program, it only prints.

Checkpoint

Check: Stopping code with break! and IEx.pry

  1. Question 1 of 2 Report.total/1 is stopped at a breakpoint before line 3. What does next do?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: Runs line 3 and stops before line 4, so the names bound on line 3 can be read

    next walks a paused function line by line; continue lets it finish, here with 420.

  2. Question 2 of 2 Audit.check/1 contains IEx.pry(). What happens when it runs under mix run, with no IEx shell?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: It prints "Cannot pry … Is an IEx shell running?" and the code runs on

    A forgotten IEx.pry/0 does not stop a program outside IEx, but it prints a line on every call, so remove it.

Choose the tool by what you need to see

The four tools overlap. Three questions decide most cases: does the code run in a shell you can type into, may you change the code, and must it stop? Answer them for the bug in front of you:

Interactive · answer and compare Which debugging tool fits your case? Answer the three questions about what you are debugging. The table marks the tools that fit, and why.
Does the code run in an IEx shell you can type into, such as iex -S mix?
Can you change the code and run it again?
What do you need to see?

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

IO.inspect/2, dbg/2, break! and IEx.pry/0 against what they need and what they show
Tool Changes to the code Each step Pauses Outside IEx
IO.inspect(label: …) Shows: the value at one step Poor one line for every value you want to see Mixed one label for every step you add it to Poor prints and runs on Good prints in scripts, tests and servers
dbg/2 Shows: the code and every step Mixed one call, at the end of the pipeline Good every step's code and value Mixed only under iex --dbg pry Good prints, with colour codes unless told not to
break! in IEx Shows: a paused function, line by line Good none: set from the iex> prompt Good next walks the function line by line Good stops until continue Poor needs the IEx shell that runs the code
IEx.pry/0 in the code Shows: the bindings at one line Poor a line in the code, and require IEx Mixed the bindings at the one line it is on Good stops until continue Mixed prints "Cannot pry" and runs on
How the answers decide
  • “Does the code run in an IEx shell you can type into, such as iex -S mix?” No: a test run, a script or a server log: rules out break! in IEx and adds a caveat to IEx.pry/0 in the code. break! needs the IEx shell that runs the code; IEx.pry/0 outside one only prints "Cannot pry" and lets the code run on.
  • “Can you change the code and run it again?” No, I want to leave it as it is: rules out IO.inspect(label: …), dbg/2 and IEx.pry/0 in the code. Each of these is a line in the code. break! is set from the prompt and changes nothing.
  • “What do you need to see?” To stop, look around and step on: rules out IO.inspect(label: …) and dbg/2. IO.inspect/2 and dbg/2 print and let the code run on; pausing needs break! or IEx.pry/0.
  • “What do you need to see?” The value after each step: adds a caveat to break! in IEx and IEx.pry/0 in the code. Pausing works, but for the values along a pipeline dbg/2 prints every step in one go.

In short: IO.inspect/2 with a label for one value in any run, dbg/2 for every step of a pipeline, with syntax_colors: [] when the output goes to a log, break! when the code must stop and you would rather not touch it, and IEx.pry/0 when you want it to stop at one exact line every time. The debugging guide adds iex --dbg pry, which turns every dbg/2 call into a place where IEx offers to pry.

Checkpoint

Check: Choosing a debugging tool

  1. Question 1 of 2 You run iex -S mix and must see how a function behaves on real data, without changing its code. Which tool fits?

    Choose one answer.

    Show the answer: Question 1 of 2

    Answer: break! on the function, then next and continue

    Of the four tools, only break! needs no change, and it needs the IEx shell that runs the code.

  2. Question 2 of 2 A pipeline gives a wrong result only in CI, where nobody can type into a shell. What do you add for one run?

    Choose one answer.

    Show the answer: Question 2 of 2

    Answer: dbg(syntax_colors: []) at the end of the pipeline

    Without a shell, print instead of pausing, and keep the printout plain for the log.

Practise: a pipeline that keeps its output

Report.build/1 turns a list of orders into a text report: one line per paid order, “CITY: amount”, the largest first. It works, but it is written as nested calls. Rewrite it as a pipeline of at least three steps without changing what it returns. The first three tests check the output, which the starter already gets right, and the last reads the file and counts the pipes. Copy the two files into an empty folder:

practice/report.ex: the starter Elixir · practice/report.ex
defmodule Report do
  @moduledoc "A text report of the paid orders, largest first."

  @doc """
  One line per paid order, "CITY: amount", the largest amount first, joined with newlines. Orders are maps with
  :city, :amount and :paid. Rewrite the body as a pipeline: the tests check that its output does not change.
  """
  def build(orders) do
    Enum.join(Enum.map(Enum.sort(Enum.filter(orders, & &1.paid), &(&1.amount >= &2.amount)), &line/1), "\n")
  end

  defp line(order), do: String.upcase(String.trim(order.city)) <> ": " <> Integer.to_string(order.amount)
end
practice/report_test.exs: the tests Elixir · practice/report_test.exs
Code.require_file("report.ex", __DIR__)
ExUnit.start(seed: 0)

defmodule ReportTest do
  use ExUnit.Case

  @orders [
    %{city: " Pune", amount: 1_200, paid: true},
    %{city: "agra ", amount: 3_400, paid: true},
    %{city: "Nagpur", amount: 9_000, paid: false}
  ]

  test "paid orders only, the largest first" do
    assert Report.build(@orders) == "AGRA: 3400\nPUNE: 1200"
  end

  test "a report of no paid orders is empty" do
    assert Report.build([]) == ""
    assert Report.build([%{city: "Kochi", amount: 500, paid: false}]) == ""
  end

  test "one paid order is one line" do
    assert Report.build([%{city: "kochi", amount: 500, paid: true}]) == "KOCHI: 500"
  end

  test "build/1 is a pipeline of at least three steps" do
    {:ok, ast} = Code.string_to_quoted(File.read!(Path.join(__DIR__, "report.ex")))
    {_ast, pipes} = Macro.prewalk(ast, 0, fn
      {:|>, _meta, _args} = node, count -> {node, count + 1}
      node, count -> {node, count}
    end)

    assert pipes >= 3
  end
end

Run elixir report_test.exs. The output tests pass and the pipeline test fails, with 0 pipes counted:

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

# The tests against the starter: its output is right, but it is not a pipeline yet.
run("""
cd practice
!elixir report_test.exs
""")

Output

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

...

  1) test build/1 is a pipeline of at least three steps (ReportTest)
     report_test.exs:26
     Assertion with >= failed
     code:  assert pipes >= 3
     left:  0
     right: 3
     stacktrace:
       report_test.exs:33: (test)


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

Result: 3/4 passed
Failed: 1 test
[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

Read the nested call from the inside out and write each call as a line: start with orders, then Enum.filter/2, Enum.sort/2 with the same comparison, Enum.map/2 and Enum.join/2, each without its first argument, which the pipe now supplies. Keep the functions and their other arguments exactly as they were: the output tests are what prove that the behaviour did not change. A finished version passes all four:

The tests against the pipeline version Python · finished_session.py
from session import run

# The same tests against the pipeline version.
run("""
cd finished
cp ../practice/report_test.exs .
elixir report_test.exs
""")

Output

$ cd finished
$ cp ../practice/report_test.exs .
$ elixir report_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: 4 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 close, a short self-check on pipes, then/2 and tap/2, inspecting, dbg/2, stopping code and choosing a tool. Your weakest idea comes first, and every answer is explained right away:

Practice that adapts to you

Practise this lesson

15 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 15 What does a |> f(b) mean?

    Choose one answer.

    Show the answer: Question 1 of 15

    Answer: f(a, b)

    The Kernel documentation: the left side goes in as the first argument of the function call on the right.

  2. Question 2 of 15 shout holds fn s -> String.upcase(s) end. Which line compiles and returns "PUNE"?

    Choose one answer.

    Show the answer: Question 2 of 15

    Answer: "pune" |> shout.()

    A function held in a name is called with a dot, in a pipeline as anywhere; then(shout) works too.

  3. Question 3 of 15 Why do pipelines fit String, Enum and Map so well?

    Choose one answer.

    Show the answer: Question 3 of 15

    Answer: Their functions take the data they work on as the first argument, which is where the pipe puts it

    When a function takes its data elsewhere, then/2 bridges the gap.

  4. Question 4 of 15 What does this return?

    Read the code, then choose one answer.

    5 |> then(fn n -> n * n end)
    Show the answer: Question 4 of 15

    Answer: 25

    then/2 returns the function's result; tap/2 returns the value it was given.

  5. Question 5 of 15 In then_tap.exs, why did Enum.uniq_by/2 still get all three names after the tap/2 step?

    Choose one answer.

    Show the answer: Question 5 of 15

    Answer: tap/2 passed its argument on and threw away what IO.puts/1 returned

    Use tap/2 for logging, metrics or a debug print that must not change the pipeline's value.

  6. Question 6 of 15 A step must send the current list to a monitoring process and go on with the same list. then/2 or tap/2?

    Choose one answer.

    Show the answer: Question 6 of 15

    Answer: tap/2, because the step is a side effect and the list must pass on unchanged

    Ask what the next step needs: the function's result (then/2) or the same value (tap/2).

  7. Question 7 of 15 What does the label option of IO.inspect/2 do?

    Choose one answer.

    Show the answer: Question 7 of 15

    Answer: It prints a name in front of the value, so each printout says where it came from

    The debugging guide: the label option decorates the output, ahead of the printed value.

  8. Question 8 of 15 In inspect_steps.exs the result kept both "Pune" and "pune". Which printout showed why at once?

    Choose one answer.

    Show the answer: Question 8 of 15

    Answer: The one labelled "after trim", which still held "Pune" and "pune" with different capitals

    Labelled printouts between steps show where a value first looks wrong.

  9. Question 9 of 15 Where did dbg/2 write its colour codes in the session?

    Choose one answer.

    Show the answer: Question 9 of 15

    Answer: Into a pipe as well as to the screen, as cat -v showed with ^[[36m

    What a terminal shows as colour, a log file stores as escape codes. syntax_colors: [] leaves them out.

  10. Question 10 of 15 What does dbg/2 at the end of a five-step pipeline give that one IO.inspect/2 at the end does not?

    Choose one answer.

    Show the answer: Question 10 of 15

    Answer: Each step's code with its value, and the file and line of the call

    For a pipeline, one dbg/2 at the end replaces an IO.inspect/2 after every step.

  11. Question 11 of 15 How many times does break! Report.total/1 stop the function, by default?

    Type a number.

    Show the answer: Question 11 of 15

    Answer: 1 times

    The IEx documentation: by default, the number of stops in a breakpoint is 1; breaks then showed 0 stops left. break! Report.total/1, 10 would stop ten times.

  12. Question 12 of 15 While Report.total/1 was stopped, what did typing orders at the prompt show?

    Choose one answer.

    Show the answer: Question 12 of 15

    Answer: The argument the paused call received, because the prompt evaluates in the function's bindings

    A pry or a breakpoint turns the prompt into a window on the stopped function: its names, then next or continue.

  13. Question 13 of 15 IEx.pry/0 stopped Audit.check/1 at once in the session. When does IEx ask first whether to allow the pry?

    Choose one answer.

    Show the answer: Question 13 of 15

    Answer: When the code reaching it runs in another process, such as a request handled by a server started from IEx

    IEx.pry/0 runs in the caller's process; when that is not the shell, the shell asks before taking over.

  14. Question 14 of 15 Which tool prints the value after every step of a pipeline with a single line of code?

    Choose one answer.

    Show the answer: Question 14 of 15

    Answer: dbg/2 at the end of the pipeline

    dbg/2 is the tool for pipelines; IO.inspect/2 for single values.

  15. Question 15 of 15 A function misbehaves only for one customer. You can run it in iex -S mix and want it to stop at one exact line every time it runs. Which tool?

    Choose one answer.

    Show the answer: Question 15 of 15

    Answer: IEx.pry/0 written at that line

    Remove the pry before you commit: outside IEx it prints "Cannot pry" on every call.

Interview questions

Warm-up: what does the pipe operator do in Elixir, and when do you use then/2?

|> takes the value on its left and passes it as the first argument of the call on its right, so text |> String.trim() |> String.split(",") is String.split(String.trim(text), ","), written in the order the steps happen. It fits Elixir’s libraries because String, Enum and Map take the data they work on first. When a step needs the value somewhere else, or the step is a function held in a name, use then/2: it calls the function with the value and returns its result, as in |> then(fn names -> %{cities: names} end). tap/2 is its partner for side effects: it calls the function, such as a log line, and passes the value on unchanged. "pune" |> shout fails to compile when shout holds a function; write shout.() or then(shout).

Two questions from debugging real code:

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

  1. Fresher to mid

    To find a failing test, a teammate added dbg/2 to a pipeline. The CI log now shows lines like ^[[36m^[[3m[lib/report.ex:12: Report.build/1]^[[0m, and setting NO_COLOR=1 in CI changed nothing. Explain the codes, how to get readable output, and how to keep such calls out of the main branch.

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

  2. Mid to senior

    A function misbehaves only on one customer's data. You run the service with iex -S mix and want to stop the function on that customer's request and look at its values. Explain how you do it, what IEx asks first, and what can go wrong while the request is paused.

    Model answer, Pro. First step and follow-ups

Key takeaways

  • a |> f(b) is f(a, b): a pipeline writes nested calls in the order they run, with each step’s value passed on as the first argument.
  • then/2 returns what its function returns, for any argument position, and tap/2 runs a function for a side effect and passes the value on unchanged.
  • IO.inspect(value, label: "…") prints a value and returns it, so it can sit between any two steps of a pipeline.
  • dbg/2 prints the code and value of every step of a pipeline, with colour codes even in a pipe or a log, unless it is given syntax_colors: [].
  • In IEx, break! stops a function without changing its code and IEx.pry/0 stops at its line; next runs one line, continue runs to the end, and outside IEx a pry only prints “Cannot pry”.
  • Choose IO.inspect/2 or dbg/2 to print and run on, and break! or IEx.pry/0 to stop and look around.

Terms in this lesson

breakpoint
A place where running code stops so that you can look at its values. In IEx, break! Module.fun/arity sets one without changing the code; next runs one more line and continue lets the code finish.
pipe operator
|>, which passes the value on its left as the first argument of the call on its right: text |> String.trim() is String.trim(text), so nested calls can be written as steps from top to bottom.

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.