skip to content

In Ruby, how do Exception#backtrace and backtrace_locations differ, and what happens to them when raise is given a custom backtrace?

level: middleimportance: should knowfreq 28%

answer

  1. strings vs Location objects
  2. path, lineno, label
  3. nil until raised
  4. string backtrace: locations nil
  5. Location arrays accepted since 3.4

basics

~20 s

backtrace returns an array of strings; backtrace_locations returns Thread::Backtrace::Location objects with path, lineno and label. Both are nil until raised. Passing strings to raise sets only backtrace; since Ruby 3.4 an array of Locations sets both.

solid answer

~40 s

`Exception#backtrace` is an array of formatted strings such as `"rates.rb:12:in 'ShippingRates.quote'"`; `backtrace_locations` is an array of `Thread::Backtrace::Location` objects whose `path`, `lineno`, `label` and `base_label` can be read without parsing. Both are `nil` for an exception that was created but never raised. `raise` accepts a third argument to replace the backtrace: an array of strings (or one string) sets `backtrace` and leaves `backtrace_locations` `nil`, while an array of Locations, accepted since Ruby 3.4 and recommended by the documentation, sets both consistently. The usual source is `caller_locations` or another error's `backtrace_locations`. `Exception#set_backtrace` does the same after creation. Prefer `backtrace_locations` in tooling, because string formats changed in Ruby 3.4 and parsing them is fragile.

code

ruby · 13 lines
ruby
def carrier(name)
  return if %w[ups fedex dhl].include?(name)

  raise ArgumentError, "unknown carrier #{name}", caller_locations(1)
end

begin
  carrier("pigeon")
rescue ArgumentError => e
  loc = e.backtrace_locations.first
  puts "#{loc.path}:#{loc.lineno}"       # the caller's line, not carrier's
  p e.backtrace.first == loc.to_s        # => true
end

go deeper

for a junior

Know that backtrace lists where the error happened, innermost first, as strings.

for a middle

Explain Location objects and their fields, why both methods are nil before raise, and how the third raise argument changes them.

for a senior

Use backtrace_locations in tooling instead of parsing strings, and use caller_locations to point DSL errors at the user's code.

for a principal

Choose what error reporters capture and trim, balancing diagnostic value against payload size and format churn across Ruby upgrades.

## Two views of the same stack When an exception is raised, Ruby records the call stack at that point. You can read it two ways: | Method | Element type | Example element | |---|---|---| | `backtrace` | `String` | `"rates.rb:12:in 'ShippingRates.quote'"` | | `backtrace_locations` | `Thread::Backtrace::Location` | an object with `path`, `lineno`, `label` | By default both describe the same frames, innermost first. A `Location` exposes: - **`path`** and **`absolute_path`**: the file; - **`lineno`**: the line number as an Integer; - **`label`** and **`base_label`**: the method or block description; - **`to_s`**: the same text as the string form. An exception created with `new` and never raised has **`nil`** for both; the stack is captured at raise time, not construction time. ## Why the objects are better for tooling The string format is meant for humans and has changed: Ruby 3.4 switched the opening quote from a backtick to a single quote and began showing the class name before the method, as in `'Integer#/'`. Ruby 4.0 changed it again for some frames, for example no longer showing `internal` frames. Code that parses backtrace strings with a regular expression breaks on such changes. Reading `lineno` and `path` from a `Location` does not. ## Supplying a custom backtrace `raise` takes an optional third argument, and `Exception#set_backtrace` does the same after creation. What you pass decides what you get: 1. **An array of `Thread::Backtrace::Location`** (from `caller_locations` or another error's `backtrace_locations`): both `backtrace` and `backtrace_locations` are set to the same frames. Accepted since **Ruby 3.4**, and the option the documentation calls the most consistent. 2. **An array of strings, or one string**: `backtrace` returns them; `backtrace_locations` becomes `nil` when set through `raise`. Through `set_backtrace`, the documentation says `backtrace_locations` keeps its original value. 3. **`nil` via `set_backtrace`**: `backtrace_locations` keeps its value, and if the exception is raised again, both are reset to the location of that raise. With no third argument, Ruby fills both from the current call stack. ## When to use a custom backtrace - **DSLs and code generators**: point the error at the user's DSL file and line rather than framework internals, using `caller_locations` captured where the DSL method was called. - **Re-raising a different class with the same frames**: the documentation's own example raises a new error with `ex.backtrace_locations` from a rescued one. Usually attaching the original as `cause` is clearer, since it keeps both stacks. - **Trimming noise** for display: better done in the reporter than by rewriting the exception. ## Practical tips - In a reporter, prefer `e.backtrace_locations || e.backtrace`: some exceptions have only strings, for instance ones given a string backtrace. - `caller_locations(1)` skips the current frame; pass a range or length to limit how much is captured. - Very deep stacks, such as a `SystemStackError`, produce long arrays; trim before sending them anywhere. In the shipping-rates gem's configuration DSL, raising `ShippingRates::ConfigurationError` with `caller_locations` from the DSL entry point makes the error show the user's `config/shipping.rb` line instead of the gem's internals. ## Checking which view you have A reporter that must handle every case can branch on what it receives: - `e.backtrace_locations` non-nil: use the objects directly. - only `e.backtrace` present: the exception was given a string backtrace, or came from code that set one; treat the strings as display text. - both `nil`: the exception was never raised, for example one built with `new` and passed around as a value. This matters most in libraries that report errors on behalf of others, where any of the three can arrive.

  • Why is backtrace nil on an exception you just created with new?
    Ruby captures the stack when the exception is raised, not when it is constructed. `IOError.new("x").backtrace` and `backtrace_locations` are both `nil` until the object goes through `raise`, or until `set_backtrace` is called on it.
  • What changed in Ruby 3.4 about custom backtraces?
    `Exception#set_backtrace`, and with it the third argument of `Kernel#raise`, `Thread#raise` and `Fiber#raise`, began accepting arrays of `Thread::Backtrace::Location`. Before that only strings were accepted, which left `backtrace_locations` empty for any custom backtrace.

saying these in an interview costs you the question

  • backtrace_locations returns strings like backtrace
  • A new exception has a backtrace as soon as it is created
  • Parsing backtrace strings is a stable way to get line numbers
  • Passing strings to raise also fills backtrace_locations
  • raise has accepted Location arrays in every Ruby 3 release