Overview
A stack trace represents the call stack at a particular point during program execution. The call stack tracks active method invocations, with each method call adding a frame to the stack and each return removing one. When an exception occurs, the runtime captures this stack state and presents it as a stack trace, showing the path of execution from the program's entry point to the error location.
Stack traces serve as the primary diagnostic tool for understanding program flow and identifying error sources. They reveal not just where an error occurred, but the complete chain of method calls that led to that point. This context proves essential for debugging, as errors often result from unexpected interaction between multiple components rather than isolated failures.
The information density of stack traces makes them invaluable during development and production debugging. Each frame typically includes the file name, line number, method name, and sometimes additional context like class names or code snippets. This allows developers to reconstruct execution flow without running a debugger or adding logging statements.
def process_data(data)
validate_input(data)
transform(data)
end
def validate_input(data)
raise ArgumentError, "Data cannot be nil" if data.nil?
end
process_data(nil)
# Traceback (most recent call last):
# 2: from example.rb:9:in `<main>'
# 1: from example.rb:2:in `process_data'
# example.rb:6:in `validate_input': Data cannot be nil (ArgumentError)
Stack traces appear in multiple contexts beyond exceptions. Profiling tools capture stack traces at intervals to identify performance bottlenecks. Debugging tools display the current call stack during execution. Error monitoring services aggregate stack traces to identify common failure patterns across deployments.
The format and detail level of stack traces varies across Ruby implementations and configurations. MRI Ruby provides standard stack traces with file and line information. JRuby includes Java stack frames when calling into Java code. The verbosity can be controlled through exception handling and configuration options, balancing detail against readability.
Key Principles
The call stack operates as a last-in-first-out data structure where each method invocation pushes a frame onto the stack and each return pops a frame off. This structure directly reflects the nested nature of method calls in procedural and object-oriented programming. When method A calls method B, which calls method C, the stack contains frames for all three methods, with C at the top.
Each stack frame contains execution context for one method invocation. This includes the return address (where to resume after the method completes), local variables, parameters, and sometimes additional metadata like the receiver object or block context. The frame preserves the state needed to resume execution when the method returns.
Stack traces capture this structure at a specific moment, typically when an exception propagates or when explicitly requested. The trace displays frames in order, either from the error point back to the entry point (top-down) or from the entry point to the error (bottom-up). Ruby uses top-down ordering by default, showing the most recent call first.
Exception propagation interacts directly with the call stack. When a method raises an exception, Ruby searches the stack for a rescue clause that can handle it. This process, called stack unwinding, pops frames one at a time, executing ensure clauses and checking for matching rescue blocks. If no handler is found, the exception reaches the top level and Ruby terminates the program, displaying the full stack trace.
def outer
middle
rescue StandardError => e
puts "Caught at outer level"
puts e.backtrace.join("\n")
end
def middle
inner
end
def inner
raise "Error in inner method"
end
outer
The distinction between the call stack and the stack trace matters for understanding what information is available. The call stack exists throughout execution, constantly changing as methods are called and return. A stack trace is a snapshot of that stack at a specific point, typically stored as an array of strings. Once captured, the trace remains static even as the actual call stack continues to evolve.
Stack depth directly impacts available memory and recursion limits. Each frame consumes stack space, and deeply nested calls can exhaust available memory, causing a stack overflow error. Ruby sets default limits on stack depth, though these vary by implementation. Tail call optimization can eliminate frames for tail-recursive calls, but Ruby does not enable this by default.
Frame filtering affects what appears in stack traces. Ruby and various frameworks filter out internal implementation details, showing only application code by default. This improves readability but can hide important context when debugging framework-level issues. The full unfiltered trace remains accessible when needed.
Ruby Implementation
Ruby provides multiple mechanisms for accessing stack trace information through the Kernel module and Exception class. The caller method returns the current call stack as an array of strings, while caller_locations returns structured objects containing more detailed information.
def method_a
method_b
end
def method_b
method_c
end
def method_c
puts caller
end
method_a
# example.rb:6:in `method_b'
# example.rb:2:in `method_a'
# example.rb:14:in `<main>'
The caller method accepts optional parameters to control the returned stack depth. The first parameter specifies how many frames to skip from the top of the stack, while the second limits the number of frames returned. This allows efficient extraction of relevant stack information without processing the entire trace.
def show_partial_stack
# Skip first frame, return 3 frames
puts caller(1, 3)
end
def a; b; end
def b; c; end
def c; d; end
def d; show_partial_stack; end
a
# Shows only frames from methods a, b, c
The caller_locations method returns Thread::Backtrace::Location objects rather than strings. These objects provide structured access to frame components through methods like path, lineno, label, and base_label. This structured format proves more efficient when programmatically processing stack traces.
def analyze_caller
caller_locations(1, 5).each do |location|
puts "Method: #{location.label}"
puts "File: #{location.path}"
puts "Line: #{location.lineno}"
puts "---"
end
end
def outer
inner
end
def inner
analyze_caller
end
outer
Exception objects capture stack traces automatically when raised. The backtrace method returns the trace as an array of strings, while backtrace_locations returns structured location objects. The set_backtrace method allows manual modification of exception stack traces, though this is rarely needed outside of framework code.
begin
raise StandardError, "Custom error"
rescue => e
puts "Exception message: #{e.message}"
puts "Exception class: #{e.class}"
puts "\nBacktrace:"
puts e.backtrace
puts "\nStructured backtrace:"
e.backtrace_locations.first(3).each do |loc|
puts " #{loc.label} at #{loc.path}:#{loc.lineno}"
end
end
Thread objects provide access to their call stacks through Thread.current.backtrace and Thread.current.backtrace_locations. This enables inspection of thread state for debugging concurrent programs. Each thread maintains its own call stack independent of other threads.
thread = Thread.new do
sleep 0.1
def nested_method
sleep 1
end
nested_method
end
sleep 0.2
puts "Thread backtrace:"
puts thread.backtrace
thread.join
Custom exceptions can override backtrace methods to modify stack trace behavior. This allows frameworks to filter internal frames, add contextual information, or format traces differently. The TracePoint API provides even deeper control over execution tracing and stack introspection.
class CustomError < StandardError
def backtrace
super.select { |line| line.include?('app/') }
end
end
def application_method
raise CustomError, "Application error"
end
begin
application_method
rescue CustomError => e
puts e.backtrace
end
Ruby's Kernel#set_trace_func and the newer TracePoint API allow monitoring execution at the instruction level. While heavier than simple stack introspection, these tools capture complete execution flow including method calls, returns, line execution, and exception events.
trace = TracePoint.new(:call, :return) do |tp|
puts "#{tp.event}: #{tp.method_id} in #{tp.path}:#{tp.lineno}"
end
trace.enable
def sample_method
result = 10 + 20
result * 2
end
sample_method
trace.disable
Practical Examples
Reading basic stack traces requires understanding the format conventions. Ruby displays the most recent call at the top, with each line showing the file path, line number, and method name. The error message appears at the bottom of the trace.
class Calculator
def divide(a, b)
perform_division(a, b)
end
def perform_division(a, b)
validate_divisor(b)
a / b
end
def validate_divisor(divisor)
raise ArgumentError, "Cannot divide by zero" if divisor.zero?
end
end
calc = Calculator.new
calc.divide(10, 0)
# Traceback (most recent call last):
# 3: from example.rb:16:in `<main>'
# 2: from example.rb:3:in `divide'
# 1: from example.rb:7:in `perform_division'
# example.rb:12:in `validate_divisor': Cannot divide by zero (ArgumentError)
Framework stack traces contain many internal frames that obscure application code. Rails applications particularly show extensive middleware and routing frames before reaching controller code. Identifying the transition between framework and application code requires recognizing common framework patterns.
# Typical Rails stack trace pattern
def index
User.find(params[:id])
end
# When User.find fails:
# activerecord-6.1.4/lib/active_record/core.rb:165:in `find'
# app/controllers/users_controller.rb:5:in `index'
# actionpack-6.1.4/lib/action_controller/metal/basic_implicit_render.rb:6:in `send_action'
# actionpack-6.1.4/lib/action_controller/metal/impl.rb:214:in `process_action'
# [... many more framework frames ...]
# The application code is at line 2, between framework frames
Multi-threaded programs produce stack traces for each thread. When an unhandled exception terminates a thread, only that thread's stack trace appears. Debugging race conditions often requires capturing stack traces from multiple threads simultaneously.
def problematic_thread_code
shared_resource = []
threads = 5.times.map do |i|
Thread.new do
begin
10.times do
shared_resource << i
raise "Thread #{i} error" if i == 3 && shared_resource.size > 15
end
rescue => e
puts "Thread #{i} trace:"
puts e.backtrace.first(5)
end
end
end
threads.each(&:join)
end
problematic_thread_code
Nested exception handling creates situations where multiple stack traces exist. The outer exception's trace shows where it was raised, while inner exceptions captured during rescue have their own traces. Accessing both traces requires storing the original exception.
def process_with_fallback(data)
primary_processor(data)
rescue => primary_error
puts "Primary processing failed:"
puts primary_error.backtrace.first(3)
begin
fallback_processor(data)
rescue => fallback_error
puts "\nFallback also failed:"
puts fallback_error.backtrace.first(3)
combined_error = StandardError.new(
"Both processors failed: #{primary_error.message} / #{fallback_error.message}"
)
combined_error.set_backtrace(primary_error.backtrace)
raise combined_error
end
end
def primary_processor(data)
raise "Primary error"
end
def fallback_processor(data)
raise "Fallback error"
end
Dynamic method invocation through send or method affects stack traces. The trace shows the send call rather than direct method invocation, which can obscure the actual code path.
class DynamicDispatcher
def process(action, data)
send("process_#{action}", data)
end
def process_create(data)
validate_create(data)
end
def validate_create(data)
raise "Invalid data" unless data.is_a?(Hash)
end
end
dispatcher = DynamicDispatcher.new
dispatcher.process(:create, nil)
# Stack trace shows 'send' in process method:
# example.rb:3:in `send'
# example.rb:3:in `process'
# This makes it less obvious that process_create was called
Blocks and lambdas appear in stack traces with special notation. Block frames show the method that yielded to them, while lambda frames indicate they're lambda invocations. This distinction helps identify whether code executed directly or through a block.
def with_timing
start = Time.now
yield
puts "Execution took #{Time.now - start} seconds"
end
def failing_operation
with_timing do
perform_work
end
end
def perform_work
raise "Work failed"
end
failing_operation
# Stack trace shows:
# example.rb:13:in `perform_work'
# example.rb:9:in `block in failing_operation'
# example.rb:2:in `with_timing'
# example.rb:8:in `failing_operation'
Error Handling & Edge Cases
Truncated stack traces occur when the call stack exceeds configured limits. Ruby truncates very deep stacks to prevent memory exhaustion, displaying only the most relevant frames. The truncation point appears as an ellipsis or note in the trace.
def recursive_method(depth)
return if depth <= 0
recursive_method(depth - 1)
rescue SystemStackError => e
puts "Stack overflow at depth #{depth}"
puts "Trace length: #{e.backtrace.length}"
puts "First 5 frames:"
puts e.backtrace.first(5)
puts "Last 5 frames:"
puts e.backtrace.last(5)
end
recursive_method(10000)
Filtered stack traces hide frames based on patterns. Rails and other frameworks filter internal frames by default, but this can remove context needed to debug framework-level issues. Accessing unfiltered traces requires disabling the filter mechanism.
# Rails automatically filters traces
begin
User.create!(invalid: :data)
rescue => e
# Clean trace shows only application code
puts "Filtered backtrace:"
puts e.backtrace.first(5)
# Full trace includes framework internals
if e.respond_to?(:backtrace_locations)
puts "\nFull backtrace available through backtrace_locations"
end
end
# Disable filtering in Rails:
# config.action_dispatch.show_detailed_exceptions = true
# config.consider_all_requests_local = true
C extension boundaries create gaps in Ruby stack traces. When Ruby code calls into native extensions, the trace shows the Ruby side but not the C call stack. This makes debugging native extension issues more difficult.
require 'json'
begin
JSON.parse('{"invalid": json}')
rescue JSON::ParserError => e
puts e.backtrace
# Trace shows JSON.parse call but not internal C parser frames
# example.rb:4:in `parse'
# example.rb:4:in `<main>'
end
Tail call optimization, when enabled, removes frames for tail-recursive calls. This improves performance and prevents stack overflows but makes stack traces less complete. Ruby disables tail call optimization by default, requiring explicit compiler flags to enable it.
# With TCO disabled (default):
def countdown(n)
return if n <= 0
countdown(n - 1)
end
begin
countdown(1000)
rescue SystemStackError
puts "Stack overflow without TCO"
end
# With TCO enabled (requires RubyVM::InstructionSequence.compile_option):
# Tail recursive calls don't add frames
# Much deeper recursion possible before stack overflow
Exception re-raising preserves or modifies stack traces depending on the approach. Using raise without arguments re-raises with the original trace, while raising a new exception creates a new trace starting from the re-raise point.
def wrapper_method
risky_operation
rescue => e
log_error(e)
raise # Preserves original backtrace
end
def risky_operation
raise "Original error"
end
def log_error(e)
puts "Original trace:"
puts e.backtrace.first(3)
end
begin
wrapper_method
rescue => e
puts "\nFinal trace:"
puts e.backtrace.first(3)
# Shows risky_operation as source, not wrapper_method
end
Swallowed exceptions lose their stack traces once the rescue block exits. Logging or storing the exception before rescue completes preserves trace information for later analysis.
error_log = []
def process_items(items)
items.each do |item|
begin
process_item(item)
rescue => e
# Swallowed exception
puts "Skipping item #{item}"
error_log << { item: item, error: e, trace: e.backtrace }
end
end
end
# Later analysis requires stored trace:
def analyze_failures
error_log.each do |log_entry|
puts "Item: #{log_entry[:item]}"
puts "Error: #{log_entry[:error]}"
puts "Trace: #{log_entry[:trace].first(3)}"
end
end
Common Pitfalls
Stack order confusion leads to misinterpreting execution flow. Ruby displays traces top-down with the most recent call first, but developers sometimes read them bottom-up, assuming the first line is where execution started. The error location appears at the bottom of the list, not the top.
def first_method
second_method
end
def second_method
third_method
end
def third_method
raise "Error here"
end
first_method
# Trace shows:
# example.rb:10:in `third_method' <- ERROR OCCURRED HERE (bottom)
# example.rb:6:in `second_method'
# example.rb:2:in `first_method' <- EXECUTION STARTED HERE (top)
#
# Reading bottom-to-top: third_method had the error
# Reading top-to-bottom gives backward execution flow
Framework noise obscures application code in production stack traces. Developers spend time analyzing framework internals instead of identifying the application code that triggered the error. Learning to scan past framework patterns improves debugging speed.
# Real Rails trace with framework noise:
def problematic_controller_action
User.find(params[:user_id]).orders.last.total
end
# Produces trace like:
# app/controllers/orders_controller.rb:15:in `problematic_controller_action'
# actionpack-6.1.4/lib/action_controller/metal/basic_implicit_render.rb:6
# actionpack-6.1.4/lib/action_controller/metal/impl.rb:214
# actionpack-6.1.4/lib/abstract_controller/callbacks.rb:42
# activesupport-6.1.4/lib/active_support/callbacks.rb:126
# [... 30+ more framework lines ...]
#
# Application code is line 1, everything else is framework
Missing line numbers in stack traces indicate issues with file loading or compilation. Dynamic code evaluation through eval or instance_eval without file context produces frames without line information, making debugging significantly harder.
code_string = <<-CODE
def dynamic_method
raise "Error in dynamic code"
end
dynamic_method
CODE
eval(code_string)
# Trace shows:
# (eval):3:in `dynamic_method'
# (eval):5:in `<main>'
# example.rb:8:in `eval'
# No source file, just "(eval)"
# Better approach includes file context:
eval(code_string, binding, "dynamic_code.rb", 1)
# Now trace shows:
# dynamic_code.rb:3:in `dynamic_method'
Exception swallowing in ensure blocks masks original errors. If an ensure clause raises an exception, it replaces the original exception and its stack trace, hiding the initial failure cause.
def problematic_ensure
raise "Original error"
ensure
# This raise replaces the original error
raise "Ensure block error"
end
begin
problematic_ensure
rescue => e
puts "Caught: #{e.message}"
puts e.backtrace.first(3)
# Only sees "Ensure block error" trace
# Original error is lost
end
# Safer pattern:
def safer_ensure
raise "Original error"
ensure
begin
cleanup_that_might_fail
rescue => cleanup_error
# Log but don't propagate
puts "Cleanup failed: #{cleanup_error.message}"
end
end
Performance costs of capturing stack traces accumulate in high-frequency operations. Each call to caller walks the entire call stack, which becomes expensive in deeply nested code or tight loops. Caching or conditionally capturing traces improves performance.
require 'benchmark'
def deep_call_stack(depth)
return capture_trace if depth <= 0
deep_call_stack(depth - 1)
end
def capture_trace
caller
end
# Expensive when done repeatedly:
Benchmark.bm do |x|
x.report("trace capture:") do
1000.times { deep_call_stack(50) }
end
end
# Better: capture conditionally
def conditional_trace(depth, should_trace)
return caller if depth <= 0 && should_trace
return nil if depth <= 0
conditional_trace(depth - 1, should_trace)
end
Comparing stack traces as strings fails when file paths differ across environments. Development, staging, and production paths vary, making string-based trace comparison unreliable. Comparing method names and relative positions works better.
def normalize_trace(backtrace)
backtrace.map do |line|
# Extract method name and relative path
if line =~ /\/app\/(.+):(\d+):in `(.+)'/
"#{$1}:#{$2}:#{$3}"
else
line
end
end
end
trace1 = ["/home/dev/app/models/user.rb:15:in `validate'"]
trace2 = ["/var/app/models/user.rb:15:in `validate'"]
# Direct comparison fails:
trace1 == trace2 # false
# Normalized comparison succeeds:
normalize_trace(trace1) == normalize_trace(trace2) # true
Tools & Ecosystem
The Better Errors gem replaces Rails default error pages with interactive debugging interfaces. It displays clean stack traces with source code context, allows REPL access at any frame, and provides variable inspection. This significantly speeds up development debugging.
# Gemfile
gem 'better_errors'
gem 'binding_of_caller'
# Development environment only
# Provides interactive console at error point
# Shows local variables and instance variables
# Allows executing code in the context of any stack frame
Error tracking services like Sentry, Bugsnag, and Rollbar aggregate stack traces from production environments. They group similar errors, track error frequency, and correlate traces with releases and deployments. This visibility into production failures drives debugging and stability improvements.
# Sentry configuration
Sentry.init do |config|
config.dsn = ENV['SENTRY_DSN']
config.breadcrumbs_logger = [:active_support_logger]
config.traces_sample_rate = 0.5
end
# Automatic trace capture:
begin
risky_operation
rescue => e
Sentry.capture_exception(e)
# Includes full backtrace, context, and environment info
end
The Stackprof gem provides statistical profiling by sampling stack traces during execution. It identifies performance bottlenecks by showing which methods appear most frequently in captured traces. This approach has lower overhead than instrumentation-based profiling.
require 'stackprof'
StackProf.run(mode: :cpu, out: 'tmp/stackprof.dump') do
# Code to profile
10000.times do
expensive_computation
end
end
# Analyze results:
# stackprof tmp/stackprof.dump --text
# Shows methods by time spent and call frequency
The Honeybadger gem provides error monitoring with enhanced context. It captures custom parameters alongside stack traces, tracks error trends, and integrates with deployment tracking to correlate errors with releases.
# Honeybadger configuration
Honeybadger.configure do |config|
config.api_key = ENV['HONEYBADGER_API_KEY']
config.env = Rails.env
end
# Add context to traces:
Honeybadger.context(user_id: current_user.id)
begin
process_payment
rescue => e
Honeybadger.notify(e, context: {
payment_amount: amount,
payment_method: method
})
end
The Pretty Backtrace gem formats stack traces for improved readability. It colorizes output, filters gem frames, and highlights application code. This makes console debugging more efficient by reducing visual noise.
require 'pretty_backtrace'
PrettyBacktrace.enable
begin
raise "Error with better formatting"
rescue => e
puts e.backtrace
# Output includes:
# - Color-coded application vs gem code
# - Shortened paths
# - Highlighted error location
end
The Rbtrace gem attaches to running Ruby processes to capture real-time stack traces. This allows debugging production issues without restarting services or adding instrumentation. It provides visibility into stuck processes and performance problems.
# In target process, add:
require 'rbtrace'
# From command line:
# rbtrace -p <pid>
# Shows current stack trace of process
# Can be invoked repeatedly to sample execution
The Logging gem provides structured logging with automatic stack trace capture. It formats traces consistently, filters frames based on configuration, and integrates with log aggregation services.
require 'logging'
logger = Logging.logger['application']
logger.level = :info
begin
risky_operation
rescue => e
logger.error("Operation failed") do
{
error: e.message,
backtrace: e.backtrace.first(10)
}
end
end
Reference
Stack Trace Access Methods
| Method | Return Type | Description |
|---|---|---|
| Kernel#caller | Array | Returns array of caller stack frames as formatted strings |
| Kernel#caller_locations | ArrayThread::Backtrace::Location | Returns structured location objects for stack frames |
| Exception#backtrace | Array | Returns exception's captured stack trace as strings |
| Exception#backtrace_locations | ArrayThread::Backtrace::Location | Returns structured location objects for exception trace |
| Thread#backtrace | Array | Returns stack trace for specific thread |
| Thread#backtrace_locations | ArrayThread::Backtrace::Location | Returns structured locations for thread stack |
Thread::Backtrace::Location Methods
| Method | Return Type | Description |
|---|---|---|
| path | String | Returns absolute path to source file |
| absolute_path | String | Returns absolute file path, resolving symlinks |
| label | String | Returns method or block label |
| base_label | String | Returns method name without block indication |
| lineno | Integer | Returns line number in source file |
| to_s | String | Returns formatted location string |
Common Stack Trace Patterns
| Pattern | Meaning | Example |
|---|---|---|
| method_name | Regular method call | validate in user.rb:15 |
| block in method_name | Block execution | block in process_data in parser.rb:42 |
| block (N levels) in method | Nested blocks | block (2 levels) in transform in data.rb:8 |
| Top-level code | in script.rb:1 | |
| class:ClassName | Class definition body | class:User in user.rb:3 |
| module:ModuleName | Module definition body | module:Helpers in helpers.rb:5 |
Stack Trace Format Elements
| Element | Description | Example |
|---|---|---|
| File path | Location of source code | app/models/user.rb |
| Line number | Specific line in file | 42 |
| Method context | Method or block name | in validate_email |
| Frame indicator | Position in call stack | 3: from |
Error Handling Patterns
| Pattern | Stack Trace Behavior | Use Case |
|---|---|---|
| raise | Preserves original backtrace | Re-raising caught exceptions |
| raise NewError | Creates new backtrace from raise point | Wrapping exceptions with context |
| raise error, message, backtrace | Sets custom backtrace | Preserving trace while changing error type |
| rescue => e; raise | Preserves exact original backtrace | Logging then re-raising |
Performance Characteristics
| Operation | Cost | Notes |
|---|---|---|
| caller | Medium | Walks entire call stack |
| caller_locations | Medium | Same cost as caller but structured |
| Exception creation | Low | Captures trace only when raised |
| backtrace | Low | Accesses already captured trace |
| caller(n) | Low | Returns only n frames |
| caller(start, length) | Low | Efficient for partial traces |
Configuration Options
| Setting | Effect | Environment |
|---|---|---|
| RUBY_THREAD_VM_STACK_SIZE | Controls stack depth limit | Shell environment variable |
| Rails backtrace cleaning | Filters framework frames | config.action_dispatch.backtrace_cleaner |
| Rails detailed exceptions | Shows full traces in development | config.action_dispatch.show_detailed_exceptions |
| Error gem filtering | Custom frame filtering | Gem-specific configuration |