
If you have spent any time working with Elixir in a production setting — or even just building a side project — you've likely encountered a stack trace. Stack traces can appear intimidating at the outset, especially since Elixir's error messages are often more detailed than those in other languages. However, they are a powerful tool for understanding what went wrong in your code.
Early in my Elixir journey, I found stack traces to be a bit overwhelming. They seemed to contain a lot of information that I didn't know how to interpret. While we have the luxury of LLMs like ChatGPT to help us understand these traces today, it's still essential to know how to read and interpret them independently.
Effective debugging is crucial when developing applications for commercial or production use. Stack traces are a key tool in your debugging arsenal, helping you quickly identify the source of an error and trace it back to its origin.
As with many other aspects of the Elixir ecosystem, stack traces are shaped by the underlying principles of simplicity, explicitness, and fault tolerance.
In this article, we'll explore the fundamentals of stack traces in Elixir, how to read and interpret them, and some best practices for debugging and error handling.
First, let's define a stack trace.
What Is a Stack Trace?
I like to think of stack traces as a story that tells you how your program reached a specific breaking point. They're a snapshot of all the function calls that led up to an error, starting from the top-level function that was called.
At a high level, stack traces reflect a program's call history — a breadcrumb trail that shows how you got to where you are. Each line in a stack trace represents a function call, starting from the most recent and going back to the initial call that triggered the error.
However, there is a catch: Elixir runs on the BEAM VM, a platform optimized for concurrency and fault tolerance. An Elixir application is actually composed of thousands of lightweight processes that run concurrently, each on its own isolated stack. So, when an error occurs, the stack trace you see is only for the process that crashed, not the entire application.
This is a crucial concept to keep in mind. A stack trace tells the local story of a single process, not the global story of an entire application. Broader system behaviour emerges from how supervisors, links, and other OTP mechanisms interact with the crashed process.
Stack Traces in the BEAM (Erlang VM)
The BEAM's approach to error handling is simple but powerful: errors are expected, processes are allowed to fail, and systems should recover gracefully. Rather than burying exceptions or attempting to rescue every failure, Elixir (and Erlang before it) encourages developers to "let it crash" and rely on supervision trees to restart failed processes.
When something goes wrong in an Elixir application, one of three primary errors can occur: raise/1
, exit/1
, or throw/1
. Each of these functions generates a stack trace that tells the story of how the error propagated through the system.
raise/1
: This function is used to raise an exception, which can be caught by atry/rescue
block. When an exception is raised, the stack trace will show the function call that raised the exception, along with the call history that led up to it.exit/1
: This function is used to terminate a process. When a process exits, it can send an exit signal to other processes, which may also generate a stack trace if those processes are linked to the exiting process.throw/1
: This function is used for non-local returns, allowing you to exit from a function and return to a different point in the call stack. It can also generate a stack trace, but its usage is less common thanraise/1
andexit/1
.
The BEAM VM is designed to handle errors gracefully, and stack traces are a key tool for understanding what went wrong and how to recover from it.
Key takeaway: Errors do not bubble up globally; they are local to processes. Supervisors and linked processes may receive notifications, but stack traces themselves belong to the failing process.
Differences Between Elixir and Erlang Stack Traces
Although Elixir runs on the BEAM VM, just like Erlang, both languages have very different approaches to presentation, semantics, and abstractions around stack traces. This is mostly due to Elixir's focus on developer experience and its goal of being a more approachable language for newcomers.
- Presentation: Elixir stack traces are designed to be more human-readable than Erlang's. They include additional context, such as module and function names, and they format the output in a way that is easier to understand at a glance.
- Semantics: Elixir's stack traces are more focused on the functional programming paradigm, while Erlang's stack traces are more aligned with the actor model. This means that Elixir's stack traces often include more information about function calls and their arguments, while Erlang's stack traces may focus more on the process and message-passing aspects of the system.
- Abstractions: Elixir provides higher-level abstractions for error handling, such as
try/rescue
andtry/catch
, which are not present in Erlang. This means that Elixir's stack traces may include additional information about the context in which the error occurred, such as the specific clause that was matched or the arguments that were passed to the function.
Reading and Interpreting an Elixir Stack Trace
So far, we have covered the general concepts around stack traces and, specifically, how they are generated in the BEAM VM. But all that theory is useless if you don't know how to read a stack trace when you see one.
Let's take a look at a simple example of a stack trace generated by an Elixir application:
** (ArithmeticError) bad argument in arithmetic expression :erlang.+("foo", 1) my_app/lib/my_module.ex:10: MyModule.add/2 (my_app 0.1.0) my_app.ex:5: MyApp.run/0
When an error occurs, Elixir prints out a stack trace that is comprised of the following elements:
- Error Type: The type of error that occurred, in this case
ArithmeticError
. - Error Message: A description of the error, in this case
bad argument in arithmetic expression
. - Function Calls: A list of function calls that led to the error, starting from the most recent call and going back to the initial call that triggered the error.
- Module and Function: The module and function where the error occurred, in this case
MyModule.add/2
. - Line Number: The line number in the source code where the error occurred, in this case
my_app/lib/my_module.ex:10
.
So, in this example, the error occurred in the add/2
function of the MyModule
module, specifically on line 10 of the my_module.ex
file. The error was caused by an attempt to add a string ("foo"
) to a number (1
), which is not a valid operation in Elixir.
Let's take a look at another example, this time a GenServer crash:
** (FunctionClauseError) no function clause matching in MyApp.Worker.handle_call/3 my_app/lib/my_app/worker.ex:15: MyApp.Worker.handle_call({:unknown, 123}, {#PID<0.142.0>, #Reference<0.0.4.6>}, %{}) (stdlib 4.0) gen_server.erl:689: :gen_server.try_handle_call/4 (stdlib 4.0) gen_server.erl:721: :gen_server.handle_msg/6
Note: Take a pause here to look at the stack trace and try to understand what went wrong on your own before reading the explanation below.
In this case, the error is a FunctionClauseError
, which means that the function handle_call/3
in the MyApp.Worker
module was called with arguments that did not match any of its defined clauses. The stack trace shows that the error occurred on line 15 of the worker.ex
file, and it provides the specific arguments that were passed to the function: {:unknown, 123}
.
The stack trace also shows the call history that led to the error, including the try_handle_call/4
and handle_msg/6
functions from the gen_server
module. This information can help you understand how the error propagated through the system and where to look for potential fixes.
A few tips that can help you read and interpret stack traces more effectively:
- Start from the top of the stack trace and work your way down. The first line usually contains the most relevant information about the error.
- Stack traces often include both Elixir and Erlang layers. Focus on your own modules first, and then look at the Erlang functions if needed.
- When chasing down an error, start from the topmost Elixir module in your codebase and work your way down.
Debugging Tools and Techniques in Elixir
So you have read the stack trace and you know where the error occurred, but how do you go about fixing it?
The following sections will cover some of the most common debugging tools and techniques available in Elixir.
Logging and Inspection
Sometimes you don't need an interactive debugger to figure out what is going on in your code. A simple IO.inspect/2
or Logger.debug/1
can go a long way in helping you understand the state of your application.
Elixir gives us a few options, but the most common ones are:
- Use
IO.inspect/2
for quick, inline data inspection:
IO.inspect(data, label: "Incoming data")
Logger.debug/1
orLogger.info/1
for production-aware logging:
require Logger Logger.error("Unexpected state in handle_call: #{inspect(state)}")
Interactive Debugging
Sometimes you need to step through your code line by line to understand what is going on. Elixir provides a couple of tools for interactive debugging: dbg/2
and IEx.pry
.
Use iex -S mix
for live exploration. Reproduce bugs interactively.
Using dbg/2
The built-in dbg/2
macro (introduced in Elixir 1.14) allows inline debugging by printing both the value of an expression and the expression itself. This is particularly useful for quickly inspecting values during runtime without modifying too much of your code.
Example:
defmodule Example do def calculate(a, b) do dbg(a + b) end end Example.calculate(2, 3) # Output: # dbg(2 + 3) #=> 5
In this example, dbg/2
prints the expression 2 + 3
and its result 5
. This makes it easier to understand what is happening in your code.
Using IEx.pry
For more advanced debugging, you can use IEx.pry
to pause the execution of your code and interact with it in a live IEx session. This is similar to setting a breakpoint in traditional debuggers.
To use IEx.pry
, you need to include the require IEx
statement and call IEx.pry/0
where you want to pause execution.
Example:
defmodule Example do def calculate(a, b) do require IEx IEx.pry() a + b end end Example.calculate(2, 3)
When the code reaches IEx.pry/0
, it will pause and open an interactive shell where you can inspect variables, evaluate expressions, and step through the code.
Note: To use IEx.pry
, you must run your application with iex -S mix
and ensure the :debugger
application starts. If the debugger is not running, you will see a message like:
** (RuntimeError) cannot pry: the shell is not running or the debugger is not enabled
Raising, Catching, and Handling Exceptions
As mentioned, Elixir, like Erlang, embraces the "let it crash" philosophy.
Instead of trying to handle every possible error within a process, Elixir encourages developers to allow processes to fail and rely on supervision trees to restart them in a clean state. This approach simplifies error handling and ensures that the system remains robust and fault-tolerant.
However, there are scenarios where raising and catching errors is necessary. For example:
- Input Validation: When user input or external data is invalid, raising an exception can provide clear feedback about what went wrong.
- Boundary Layers: At the boundaries of your system (e.g., APIs, file I/O, or database interactions), catching errors allows you to handle failures gracefully and provide meaningful error messages to users or clients.
- Critical Cleanup: In cases where resources (e.g., files, sockets, or database connections) need to be cleaned up, using
try/after
ensures that cleanup happens even if an error occurs.
Why Raise and Catch Errors?
While the "let it crash" philosophy works well for many scenarios, there are times when you need more control over error handling, including:
- Graceful Degradation: In user-facing applications, you can catch errors to provide a fallback or a friendly error message instead of crashing the process.
- Error Context: Raising custom exceptions with meaningful messages can make debugging easier by providing more context about what went wrong.
- Controlled Recovery: In some cases, you may want to catch an error, log it, and retry the operation or take an alternative action.
For example:
def fetch_user_data(user_id) do try do # Simulate a risky operation raise "User not found" if user_id == nil {:ok, %{id: user_id, name: "John Doe"}} rescue e in RuntimeError -> {:error, e.message} end end
In the example above, the function raises an exception if the user_id
is nil
. However, it also catches the exception and returns an error tuple, allowing the caller to handle the failure gracefully.
By combining the "let it crash" philosophy with deliberate error handling where appropriate, you can build resilient and user-friendly systems.
Raising Exceptions
Use raise/1
or raise/2
to deliberately trigger errors:
raise "Something went wrong!" raise ArgumentError, "invalid argument passed"
Define custom exceptions for clarity:
defmodule MyApp.MyError do defexception message: "default message" end
And raise it like this:
raise MyApp.MyError, message: "Custom error"
Catching Exceptions
You can catch errors using try/rescue
:
try do risky_call() rescue e in RuntimeError -> IO.puts("Caught: #{e.message}") end
For throw/1
, use try/catch
:
try do throw(:error) catch :throw, :error -> "Caught throw" end
Use try/after
to ensure cleanup happens:
try do File.read!("some_file.txt") after IO.puts("Finished file operation") end
Best Practice: Don't rescue all exceptions blindly. It breaks the "let it crash" principle and can hide bugs.
Rescuing exceptions truncates the stack trace to the rescue point. If you catch too early, you lose important debugging context.
Next Steps
Create a sandbox project and experiment with different error scenarios. Here are a few ideas that you can try:
- Create a simple GenServer and trigger a
FunctionClauseError
by sending an unexpected message. - Raise an exception in a
try
block and see how the stack trace changes when you rescue it. - Use
dbg/2
to inspect the call stack and see how it changes as you step through your code. - Protocols and behaviours are a great way to learn about stack traces. Create a protocol and implement it in different modules, then call the protocol function with different arguments to see how the stack trace changes.
- Use
Logger
to log different levels of messages and see how they appear in the console.
In summary, treat errors as opportunities to learn and improve your code. Stack traces are a powerful tool for understanding what went wrong and how to fix it. By mastering stack traces and debugging techniques, you'll be better equipped to build robust and reliable Elixir applications.
Wrapping Up
In this article, we covered a lot of ground on stack traces and debugging in Elixir. Here are the key takeaways:
- Stack traces in Elixir tell the story of a single process, not the entire application.
- Elixir's "let it crash" philosophy means errors are expected and systems should recover gracefully.
- To read stack traces effectively, start from the top and focus on your application's modules.
- Debugging tools like
IO.inspect/2
,Logger
, and interactive debugging can help you understand errors. - Exception handling should be purposeful and limited, preserving the valuable context that stack traces provide.
- Well-designed supervision trees and meaningful error messages make production systems more resilient.
As with anything in programming, debugging is a skill that improves with practice. The more you work with Elixir and its stack traces, the more comfortable you'll become in reading and interpreting them.
Further Reading and Resources
To deepen your understanding of error handling and debugging in Elixir, I recommend the following resources:
- The official Elixir documentation on try, catch, and rescue
- The Erlang docs for insights into errors and underlying BEAM behavior
- "Elixir in Action" by Saša Jurić, which covers OTP patterns and error handling extensively
- "Designing for Scalability with Erlang/OTP" by Francesco Cesarini and Steve Vinoski for a deeper understanding of fault tolerance
- The Elixir Forum for community discussions on debugging techniques
- Understanding Elixir error messages and stack traces in AppSignal's Learning Center
Happy coding!
Wondering what you can do next?
Finished this article? Here are a few more things you can do:
- Subscribe to our Elixir Alchemy newsletter and never miss an article again.
- Start monitoring your Elixir app with AppSignal.
- Share this article on social media
Most popular Elixir articles
A Complete Guide to Phoenix for Elixir Monitoring with AppSignal
Let's set up monitoring and error reporting for a Phoenix application using AppSignal.
See moreEnhancing Your Elixir Codebase with Gleam
Let's look at the benefits of using Gleam and then add Gleam code to an Elixir project.
See moreUsing Dependency Injection in Elixir
Dependency injection can prove useful in Elixir. In this first part of a two-part series, we'll look at some basic concepts, core principles, and types of dependency injection.
See more

Allan MacGregor
Guest author Allan is a software engineer and entrepreneur based in Canada. He has 15 years of industry experience, and is passionate about functional programming and Elixir development.
All articles by Allan MacGregorBecome our next author!
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!
