elixir

Advanced Debugging in Elixir with IO.inspect

Pieter Claerhout

Pieter Claerhout on

Advanced Debugging in Elixir with IO.inspect

When writing Elixir, most developers quickly get familiar with IO.inspect as a quick way to see what's happening inside their code. But what many overlook is that IO.inspect is far more powerful than just a method that prints a variable to the console.

In fact, with the right options and placement, IO.inspect can become a precise, highly targeted debugging tool, one that doesn't interrupt your program flow and works seamlessly with Elixir's functional pipelines.

This post will walk you through both the fundamentals and advanced patterns for using IO.inspect effectively. By the end, you'll know how to control output formatting, label your prints for clarity, debug concurrent processes, and even integrate conditional or file-based inspection.

The Basics of IO.inspect for Elixir

IO.inspect is defined like this:

Elixir
IO.inspect(item, opts \\ [])
  • item: Any Elixir value you want to inspect.
  • opts: Keyword list of options that controls how the term is printed, as defined in Inspect.Opts.

By default, it writes the inspected value to the standard output (:stdio) and then returns the term unchanged.

That last part is key. Because it returns the term, you can drop it anywhere in a function or pipeline without breaking the flow.

Example:

Elixir
defmodule Demo do def run do IO.inspect(%{name: "Alice", age: 30}) end end

Prints:

Shell
%{age: 30, name: "Alice"}

Caveat: When IO.inspect Is the Last Call in a Function

Because IO.inspect returns its argument, if it's the last expression in your function, that inspected value becomes the function's return value.

That's fine if you intend to return it, but it can lead to subtle bugs if you were only printing it for debugging and expected a different return.

Take this example:

Elixir
def fetch_user(id) do Repo.get(User, id) |> IO.inspect(label: "Fetched user") end

Here, the function returns the user struct as normal, which is fine.

Now, consider:

Elixir
def log_and_return do IO.inspect("Done!") end

This returns "Done!" instead of, say, :ok.

If you want to print something but return a different value, make the return explicit:

Elixir
def log_and_return do IO.inspect("Done!") :ok end

Or if you're in a pipeline:

Elixir
value |> IO.inspect(label: "Debug") |> do_something_else()

Rule of thumb: if IO.inspect is the last expression in a function, be explicit about what you want to return.

Strategic Placement with the Pipe Operator

Because IO.inspect returns its argument, it fits perfectly in the middle of a pipeline:

Elixir
users |> IO.inspect(label: "Before filtering") |> Enum.filter(& &1.active) |> IO.inspect(label: "After filtering") |> Enum.map(& &1.name)

This lets you peek into the data flow at exactly the point you want, without rewriting your code into intermediate variables.

A neat trick is to place multiple IO.inspect calls at different stages, each with a distinct label, so you can see how the data changes step by step.

Using Labels for Clarity in Elixir

Without labels, multiple inspection outputs can be hard to tell apart. The label option solves this:

Elixir
IO.inspect(data, label: "After filtering")

Prints:

Shell
After filtering: [%{name: "Alice"}, %{name: "Bob"}]

When debugging a pipeline with several inspect points, labels make the output self-describing. This is especially useful when you're debugging multiple similar data structures in the same run.

Pretty Printing and Formatting Output

Sometimes, especially with large maps or deeply nested lists, the default single-line output is hard to read. That's where formatting options defined in Inspect.Opts come in.

OptionDescriptionDefault
:prettyIf set to true, enables pretty printing.false
:limitLimits the number of items inspected for tuples, bitstrings, maps, lists and any other collection of items, with the exception of printable strings and printable charlists which use the :printable_limit option. If you don't want to limit the number of items to a particular number, use :infinity. It accepts a positive integer or :infinity.50
:printable_limitLimits the number of characters that are inspected on printable strings and printable charlists. You can use String.printable?/1 and List.ascii_printable?/1 to check if a given string or charlist is printable. If you don't want to limit the number of characters to a particular number, use :infinity. It accepts a positive integer or :infinity.4096
:widthNumber of characters per line used when pretty is true or when printing to IO devices. Set to 0 to force each item to be printed on its own line. If you don't want to limit the number of items to a particular number, use :infinity.80
:charlistsWhen :as_charlists, all lists will be printed as charlists, non-printable elements will be escaped. When :as_lists, all lists will be printed as lists. When the default :infer, the list will be printed as a charlist if it is printable, otherwise as a list. See List.ascii_printable?/1 to learn when a charlist is printable.:infer

Example:

Elixir
data = for i <- 1..100 do %{id: i, value: String.duplicate("x", i)} end IO.inspect(data, pretty: true, limit: 5)

Prints:

Shell
[ %{id: 1, value: "x"}, %{id: 2, value: "xx"}, %{id: 3, value: "xxx"}, %{id: 4, ...}, %{...}, ... ]

Here, we can see the first five entries, and the rest are summarized with ....

Coloring and Styling Output

Elixir's inspect options support syntax coloring: very handy when your terminal is full of logs.

Example:

Elixir
IO.inspect(data, syntax_colors: [ atom: :blue, string: :green, number: :red ], pretty: true )

This makes atoms blue, strings green, and numbers red in your console output.

Colors can be any IO.ANSI.ansidata/0 as accepted by IO.ANSI.format/1.

If you want to use the default colors (like what is used in IEx), you can use IO.ANSI.syntax_colors/0:

Elixir
IO.inspect(data, syntax_colors: IO.ANSI.syntax_colors(), pretty: true )

Note: The colors are only visible if your terminal supports ANSI colors.

Conditional Inspection

Sometimes you only want to inspect if a certain condition is true: for example, when a debug flag is enabled or when a value crosses a threshold.

Here's an example with a debug flag:

Elixir
def debug(term) do if Application.get_env(:my_app, :debug) do IO.inspect(term, label: "Debug") end term end

You can use the function like this:

Elixir
users |> Enum.filter(& &1.active) |> debug() |> Enum.map(& &1.name)

You can toggle the output by setting config :my_app, :debug, true or false.

Capturing Inspect Output Instead of Printing

If you want to get the inspected form of a value without printing it, use inspect/2 (without the IO. prefix):

Elixir
string_representation = inspect(data, pretty: true)

This is useful if you want to:

  • Write the debug output to a file
  • Send it to a logging service
  • Include it in an exception message

Example:

Elixir
File.write!("debug.log", inspect(data, pretty: true))

You can also redirect IO.inspect output to another device:

Elixir
IO.inspect(data, label: "Debug", device: :stderr)

Debugging Concurrency and Async Code

When you use IO.inspect in async code, the output may arrive out of order, making it hard to follow. Adding identifiers or timestamps can help.

Example:

Elixir
tasks = for id <- 1..3 do Task.async(fn -> IO.inspect(self(), label: "PID #{id}") :timer.sleep(100) id * id end) end Enum.map(tasks, &Task.await/1)

Prints:

Shell
PID 3: #PID<0.112.0> PID 2: #PID<0.111.0> PID 1: #PID<0.110.0>

Including the PID or a unique request ID in your label helps you trace which output belongs to which process.

Advanced Trick: Inspecting and Pattern Matching in One Go

You can combine pattern matching with inspection to see exactly what's being matched:

Elixir
%{id: id} = IO.inspect(user, label: "User before insert")

This inspects the user variable before pattern matching extracts the id.

You can also place IO.inspect inside a guard to conditionally print:

Elixir
case user do %{role: :admin} = u -> IO.inspect(u, label: "Admin user") _ -> :ok end

In this last example, "Admin user" will only be printed if the user has the role :admin.

Using dbg/2 for Richer Inspection

Since Elixir 1.14, we have dbg/2, a built-in debugging helper that works like IO.inspect but also shows the code expression that produced the value.

You can use it like this:

Elixir
users |> Enum.filter(& &1.active) |> dbg()

Which prints:

Shell
users #=> [%{active: false, name: "Alice"}, %{active: true, name: "Bob"}] |> Enum.filter(& &1.active) #=> [%{active: true, name: "Bob"}]

This makes it much easier to understand where in your code the inspected value is coming from, especially when you're inspecting multiple similar-looking values.

You can also pass options similar to IO.inspect:

Elixir
dbg(users, label: "Active users", pretty: true)

Here are the key differences with IO.inspect:

  • Shows the code expression automatically.
  • Output format is slightly more verbose.
  • Same return behavior, returns the inspected value, so you can keep it in a pipeline.
  • Best for interactive debugging during development.

When IO.inspect is Not Enough

While IO.inspect is a fantastic quick-and-dirty tool, there are times when you need more powerful debugging:

  • IEx.pry: drops you into an interactive REPL inside the running process.
  • :observer.start/0: Erlang's GUI for monitoring processes, memory, and more.

The trick is to know when to reach for IO.inspect and when to switch to one of these other tools.

Setting Default Options for IO.inspect in IEx

When using IEx, you can configure the default options for IO.inspect using the IEx.configure/1 function:

Elixir
IEx.configure( colors: [ syntax_colors: [ number: :light_yellow, atom: :light_cyan, string: :light_black, boolean: :red, nil: [:magenta, :bright] ], ls_directory: :cyan, ls_device: :yellow, doc_code: :green, doc_inline_code: :magenta, doc_headings: [:cyan, :underline], doc_title: [:cyan, :bright, :underline] ] )

You can easily put this in your .iex.exs file so that it's applied automatically every time you open the shell.

Creating a Reusable Inspect Helper

Here's a neat way to wrap IO.inspect/2 into your own helper module with preferred defaults. This way, you can keep consistent inspection output without repeating options everywhere:

Elixir
defmodule MyApp.Debug do @moduledoc """ Convenience wrapper around `IO.inspect/2` with sensible defaults. """ @default_opts [ label: "DEBUG", pretty: true, limit: :infinity, width: 120, syntax_colors: IO.ANSI.syntax_colors() ] @doc """ Inspects a value with default debug options. This is pipe-friendly: it returns the given value unchanged after inspecting it. ## Examples iex> [1, 2, 3] ...> |> Enum.map(&(&1 * 2)) ...> |> MyApp.Debug.inspect() [2, 4, 6] """ def inspect(term, opts \\ []) do term |> IO.inspect(Keyword.merge(@default_opts, opts)) end end

You can use it like this:

Elixir
result = users |> Enum.filter(&(&1.active?)) |> MyApp.Debug.inspect(label: "Active users") |> Enum.map(& &1.name) |> MyApp.Debug.inspect(label: "User names")

This way:

  • You get pretty printing (pretty: true) by default.
  • Lists and maps are not truncated (limit: :infinity).
  • A label is always shown (DEBUG unless overridden).
  • You can still override any option per call.
  • It works fine when it's in a pipeline.

Handy Visual Studio Code snippets

To make using IO.inspect faster and more consistent, you can configure editor snippets in Visual Studio Code so you don't have to type repetitive boilerplate each time.

In Visual Studio Code, open: Code -> Settings… -> Configure Snippets -> Elixir

Add the following snippet definitions:

JSON
{ "inspect": { "prefix": "lin", "body": "IO.inspect($1, label: \"$1\", pretty: true)", "description": "IO.inspect with label." }, "inspectSelectedText": { "prefix": "sin", "body": "IO.inspect($TM_SELECTED_TEXT, label: \"${TM_SELECTED_TEXT/(.*)/${1:/upcase}/}$0\", pretty: true)", "description": "IO.inspect with selected text as label." }, "pipeInspect": { "prefix": "pin", "body": "|> IO.inspect(label: \"$1\", pretty: true)", "description": "IO.inspect with pipe." }, "pipeInspectWithFileReference": { "prefix": "pinf", "body": "|> IO.inspect(label: \"$TM_FILEPATH:$TM_LINE_NUMBER$0\", pretty: true)", "description": "IO.inspect with pipe and file reference." }, "inspectFromClipboard": { "prefix": "cin", "body": "IO.inspect($CLIPBOARD, label: \"${CLIPBOARD/(.*)/${1:/upcase}/}$0\", pretty: true)", "description": "IO.inspect clipboard content." } }

With these in place, you'll have short prefixes to insert commonly used IO.inspect/2 patterns:

PrefixDescription
linIO.inspect with label
sinIO.inspect using selected text as label
pinIO.inspect in a pipeline
pinfIO.inspect in a pipeline with file and line reference
cinIO.inspect clipboard content

This way, you can quickly drop in debug output with consistent formatting, colors, and labels, without breaking your flow.

Suppose you are transforming a list of user maps and want to check intermediate results inside a pipeline:

Elixir
users |> Enum.filter(&(&1.active)) |> IO.inspect(label: "After filter", pretty: true) |> Enum.map(& &1.email)

With the snippet defined above, you don't have to type all that. Just type pin, hit Tab, and you get:

Elixir
|> IO.inspect(label: "", pretty: true)

You can immediately type your label (e.g., "After filter") and continue coding. This keeps your debugging consistent, colorful, and fast, without breaking your flow.

Best Practices

Now, let's finally look at a few best practices when using IO.inspect.

  • Use labels liberally: Unlabelled output is harder to parse.
  • Limit output: Use limit and pretty for large collections.
  • Avoid leaving it in production unless intentional.
  • Wrap it in helper functions when you want conditional control.
  • Tag concurrent output with PIDs, timestamps, or request IDs.
  • Credo can be used to detect unintentional calls to IO.inspect in a CI/CD pipeline.

Wrapping Up

IO.inspect may look like a humble debugging tool, but in Elixir, it's a powerful way to see what's going on without breaking your code's flow. By combining it with labels, formatting, conditional output, and process context, you can get precise insights into your program's behavior, all without leaving your editor.

Happy coding!

Wondering what you can do next?

Finished this article? Here are a few more things you can do:

  • Share this article on social media
Pieter Claerhout

Pieter Claerhout

Guest author Pieter is a CTO who has been programming since the early 2000s. He speaks Elixir, PHP, Python, Vue, Go, and many other languages. He's an avid cyclist and father of two lovely girls and a cat.

All articles by Pieter Claerhout

Become our next author!

Find out more

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal
AppSignal monitors your apps