Debugging Essentials

In this introductory guide, we’re only scratching the surface of debugging in Elixir, but I’d like to introduce three vital tools that will be beneficial while exploring the code examples in this book.

dbg/2

Elixir version 1.18 includes the powerful debugging tool dbg/2. It not only prints the passed value, returning it simultaneously, but also outlines the code and location. Here’s an example:

iex(1)> name = "Elixir"
"Elixir"
iex(2)> dbg(name)
[iex:2: (file)]
name #=> "Elixir"

"Elixir"
iex(3)> dbg(IO.puts("Hello World!"))
Hello World!
[iex:3: (file)]
IO.puts("Hello World!") #=> :ok

:ok
Beginners often find it odd that dbg/2 and IO.inspect/2 return the value they print. This becomes useful later, once you chain functions together with the pipe operator (|>), because the printed value stays in the chain and the code keeps flowing.
The /2 after dbg is the function’s arity, the number of arguments it takes. So dbg/2 is a function that accepts two arguments, even though our example only passes one. That works because the second argument has a default value, so it is optional. We cover arity and default arguments in later chapters.

IO.inspect/2

The function IO.inspect(item, opts \\ []) is a staple in Elixir debugging. Although it’s less feature-rich than dbg/2, its usage remains widespread, given its history and straightforward application. You can inject IO.inspect/2 into your code at any point, printing the value of an expression to your console - perfect for verifying a variable’s value or a function call’s result.

For example:

iex> name = "Elixir"
"Elixir"
iex> IO.inspect(name)
"Elixir"
"Elixir"
Feel free to always use dbg/2 instead of IO.inspect/2. However, if you’re working with older codebases, you’ll likely encounter IO.inspect/2.

i/1

Finally, the IEx helper function i/1 offers useful insights about any data type or structure. Launch an IEx session with iex in your terminal, and then call i() with any term to obtain information about it.

Here’s an example:

iex> name = "Elixir"
"Elixir"
iex> i(name)
Term
  "Elixir"
Data type
  BitString
Byte size
  6
Description
  This is a string: a UTF-8 encoded binary. It's printed surrounded by
  "double quotes" because all UTF-8 encoded code points in it are printable.
Raw representation
  <<69, 108, 105, 120, 105, 114>>
Reference modules
  String, :binary
Implemented protocols
  Collectable, IEx.Info, Inspect, JSON.Encoder, List.Chars, String.Chars

This output tells us that "Elixir" is a 6-byte BitString and gives further details like the string’s raw representation and the protocols it implements.

Agentic Coding Tip: Use IO.inspect/2 for Debug Traces, Not IO.puts

When an AI agent adds a debug trace to a function, it reaches for IO.puts because that’s what Hello World tutorials use. That is fine for a string literal and nothing else. The moment the value is a map, tuple, list, atom or nil, IO.puts throws away half the information:

iex> user = %{id: 42, name: "Ada"}
iex> IO.puts(user)
** (Protocol.UndefinedError) protocol String.Chars not implemented for %{id: 42, name: "Ada"}

iex> IO.inspect(user)
%{id: 42, name: "Ada"}
%{id: 42, name: "Ada"}

IO.inspect/2 prints the Elixir representation (what you would type to recreate the value) and returns the value itself, which means you can drop it into a pipe without breaking the chain:

[1, 2, 3]
|> Enum.map(&(&1 * 2))
|> IO.inspect(label: "after map")
|> Enum.sum()

The label: option is the small detail that pays off when you have more than one IO.inspect in the same function. For richer output that includes the expression and location, reach for dbg/2 shown at the top of this chapter.

Rule to add to your project’s CLAUDE.md:

For temporary debug output in Elixir code, use
`IO.inspect(value, label: "something")` or `dbg(value)`,
not `IO.puts`. `IO.puts` is for human-readable strings
and will crash on maps, tuples, structs, or `nil`. Remove
all debug `IO.inspect` / `dbg` calls before committing.