Exceptions and Error Handling

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Ruby reports failure with exceptions: an object of a class that descends from Exception is raised, the interpreter unwinds the call stack looking for a matching rescue, and ensure clauses run on the way. The page covers the whole model: how to raise and rescue, the built-in hierarchy and why a bare rescue only sees StandardError, how to design your own error classes, how to read the messages and backtraces Ruby produces (including error_highlight and did_you_mean), cause chains, exceptions versus control flow (catch/throw), Timeout and its caveats, how a program ends (exit, at_exit, abort), warnings, exceptions in threads, testing error paths, and the RuboCop cops that police all of it.

It builds on Methods and Parameters, Blocks, Procs and Lambdas and Control Expressions (break, next, return and redo are control flow, not exceptions), and uses the classes from Classes and Objects and Modules and Mixins. Later pages use it: Threads and the GVL, Testing with Minitest, IO, Files and Directories, Networking and HTTP, Command-Line Programs, Processes, Signals and Shell, Style, Linting and Static Analysis and Common Mistakes and Gotchas. For other languages' approaches, see Python, Java, Kotlin, C#, Go and Rust (errors are values in the last two), and Swift.

Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The output shown is the real output; scripts that end with an uncaught exception show what Ruby printed to standard error. The examples use the shelf gem (a small library catalogue, see the shelf gem source) and are run with ruby -Ilib, so require "shelf" finds it.

The model in one page

An exception is raised with raise, travels up the call stack, and is stopped by the first rescue clause whose class list matches. Four clauses make up a handler, and they always appear in this order: the body, zero or more rescue clauses, an optional else, and an optional ensure.

# frozen_string_literal: true

NETWORK_ERRORS = [IOError, SystemCallError].freeze

def handler_anatomy
  # body: the code whose exceptions are covered
rescue ArgumentError, KeyError => e # a list of classes, optionally captured
  warn e.message
rescue *NETWORK_ERRORS # a splatted array of classes or modules
  retry
rescue StandardError => e # what a bare "rescue" means
  warn "unexpected: #{e.class}"
  raise # re-raise the exception being handled
else
  # runs when the body raised nothing
ensure
  warn "always runs"
end

The statements that make up the whole error-handling vocabulary of the language:

Construct Meaning

raise / fail

Kernel#raise (with the alias fail) creates and raises an exception. With no argument inside a rescue clause it re-raises the exception being handled.

begin ... rescue ... else ... ensure ... end

The handler. begin/end may be omitted in a method body, a do/end block, a lambda written with do/end, and a class or module body.

rescue ClassA, ClassB

Matches when ClassA === error or ClassB === error is true (so subclasses match). No class list means StandardError.

rescue => name

Binds the exception to a local variable. Also usable after a class list.

else

Runs when the body finished without raising. Exceptions raised in it are not handled by the sibling rescue clauses.

ensure

Runs in every case (normal end, rescued exception, unhandled exception, return, break, throw, exit). Its value is discarded.

retry

Inside a rescue clause only: restarts the whole begin body.

$! and $@

The exception being handled and its backtrace; nil outside a rescue clause. require "English" gives them readable names, $ERROR_INFO and $ERROR_POSITION.

catch / throw

Non-local exit that is not an error: throw unwinds to the matching catch.

exit, exit!, abort, at_exit

End the program. exit and abort raise SystemExit so that ensure clauses and at_exit handlers run.

warn, Warning

Non-fatal diagnostics on standard error, filterable by category.

Timeout.timeout

Interrupts a block after a time limit (library timeout).

The flow of a handler, including retry and the clauses that always run:

flowchart TD S(["begin: run the body"]) --> Q1{"exception raised?"} Q1 -->|no| EL["else clause, if any"] Q1 -->|yes| Q2{"does a rescue clause match?"} Q2 -->|yes| R["run that rescue clause"] Q2 -->|no| U["not handled here"] R -->|retry| S R --> EN["ensure clause, if any"] EL --> EN U --> EN EN --> D1{"handled, or no exception?"} D1 -->|yes| N(["continue after end"]) D1 -->|no| P(["exception keeps unwinding to the caller"])

The simplest complete example is a method with a method-level handler. Shelf::Isbn.new raises ArgumentError for a malformed ISBN, the rescue clause turns that into a nil result, the else clause runs only for valid input, and the ensure clause always reports. The method returns the value of its else clause on success and of its rescue clause on failure, because the handler is an expression.

# frozen_string_literal: true

require "shelf"

# A method body is an implicit begin block: rescue, else and ensure go
# straight after the last statement.
def load_isbn(text)
  isbn = Shelf::Isbn.new(text)
rescue ArgumentError => e
  puts "rejected: #{e.message}"
  nil
else
  puts "accepted: #{isbn}"
  isbn
ensure
  puts "checked #{text.inspect}"
end

p load_isbn("978-0-306-40615-7")
p load_isbn("123")
accepted: 9780306406157
checked "978-0-306-40615-7"
#<data Shelf::Isbn digits="9780306406157">
rejected: invalid ISBN: 123
checked "123"
nil

Note that the printed Shelf::Isbn uses the Ruby 3.4+ inspect format of Data objects, and that in both runs the ensure output comes after the else or rescue output and before the caller prints the returned value: ensure runs when control is about to leave the method.

Raising exceptions

Kernel#raise has two calling sequences, documented as raise(exception, message = exception.to_s, backtrace = nil, cause: $!) and raise(message = nil, cause: $!). The first argument decides what is raised:

Call What is raised

raise "disk full"

A RuntimeError with that message.

raise ArgumentError

A new ArgumentError (calls ArgumentError.exception, which is new with no arguments); the message is the class name.

raise ArgumentError, "bad ISBN"

A new ArgumentError with the message (ArgumentError.exception("bad ISBN")).

raise error

That exception object, with its message and, if it already has one, its backtrace.

raise error, "new message"

A copy of the object made by error.exception("new message"), with the new message.

raise obj

Anything that answers exception with an Exception instance, such as the Complaint class below.

raise

Inside a rescue clause: the exception being handled ($!). Anywhere else: a RuntimeError with an empty message (unhandled exception is printed if nobody rescues it).

raise 42, raise String

A TypeError, "exception class/object expected".

# frozen_string_literal: true

# An object that is not an exception but knows how to build one.
class Complaint
  def exception(message = "complaint filed") = RuntimeError.new(message)
end

bad_isbn = ArgumentError.new("bad ISBN")

forms = {
  "message only" => -> { raise "disk full" },
  "class" => -> { raise ArgumentError },
  "class, message" => -> { raise ArgumentError, "bad ISBN" },
  "instance" => -> { raise bad_isbn },
  "instance, message" => -> { raise bad_isbn, "other message" },
  "exception method" => -> { raise Complaint.new, "via #exception" },
  "no arguments" => -> { raise },
  "not an exception" => -> { raise 42 },
  "class, not an Exception" => -> { raise String }
}

forms.each do |name, form|
  form.call
rescue StandardError => e
  puts "#{name.ljust(24)} #{e.class}: #{e.message.inspect}"
end
message only             RuntimeError: "disk full"
class                    ArgumentError: "ArgumentError"
class, message           ArgumentError: "bad ISBN"
instance                 ArgumentError: "bad ISBN"
instance, message        ArgumentError: "other message"
exception method         RuntimeError: "via #exception"
no arguments             RuntimeError: ""
not an exception         TypeError: "exception class/object expected"
class, not an Exception  TypeError: "exception class/object expected"

Source: Kernel#raise

fail is a synonym of raise. Some style guides reserve fail for "this method failed" and raise for re-raising inside a rescue; RuboCop’s Style/SignalException enforces raise everywhere by default (only_raise), and so does this page.

Two more arguments exist. The third positional argument sets the backtrace: an array of Thread::Backtrace::Location (Ruby 3.4; the most consistent option, taken from caller_locations or from another exception’s backtrace_locations), an array of strings, a single string, or nil. When it is strings, backtrace_locations is nil. The keyword cause: sets the cause explicitly (see Wrapping and chaining); it cannot be the only argument. Every other keyword is not passed on: it is collected into a positional Hash, which becomes the message.

# frozen_string_literal: true

class ConfigError < StandardError
  attr_reader :code

  def initialize(message = "bad configuration", code: 1)
    super(message)
    @code = code
  end
end

# Keywords other than cause: are NOT passed to the constructor; they become
# one positional Hash, which is used as the message.
begin
  raise ConfigError, code: 5
rescue ConfigError => e
  puts "#{e.inspect} code=#{e.code}"
end

begin
  raise ConfigError.new("bad port", code: 5)
rescue ConfigError => e
  puts "#{e.inspect} code=#{e.code}"
end

# An explicit backtrace (array of strings) replaces the real one.
begin
  raise ConfigError, "from the DSL", %w[settings.rb:3 framework.rb:1]
rescue ConfigError => e
  p e.backtrace
  p e.backtrace_locations
end

# cause: alone is rejected.
begin
  raise cause: RuntimeError.new("lonely")
rescue ArgumentError => e
  puts e.message
end

# cause: nil suppresses the implicit cause.
begin
  begin
    Integer("x")
  rescue ArgumentError
    raise ConfigError, "wrapped", cause: nil
  end
rescue ConfigError => e
  p e.cause
end
#<ConfigError: {code: 5}> code=1
#<ConfigError: bad port> code=5
["settings.rb:3", "framework.rb:1"]
nil
only cause is given with no arguments
nil

Source: Kernel#raise

The last case of the first group is a common mistake: raise ConfigError, code: 5 does not call ConfigError.new(code: 5). To pass keyword arguments to an exception’s constructor, build the object yourself and raise it, raise ConfigError.new("bad port", code: 5). (RuboCop’s Style/RaiseArgs prefers the exploded form raise Klass, "message" when there is a single message argument, and accepts the object form otherwise.)

Rules that follow from the mechanics above:

  • Raise a class and a message for the common case, raise Shelf::NotFoundError, digits: the class is instantiated for you (Exception.exception is new), the backtrace starts at the raise line, and RuboCop has no objection.

  • The message should say what was wrong and, if possible, which value: invalid ISBN: 123 rather than invalid. Messages conventionally start in lower case and have no trailing period, because Ruby appends the class name and often the location: file.rb:4:in 'Object#parse': invalid ISBN: 123 (ArgumentError).

  • Only exceptions can be raised. raise with anything else is a TypeError, and Exception itself (rather than a subclass) is almost never what you want to raise.

  • The backtrace is set when the exception is raised, not when it is created. ArgumentError.new.backtrace is nil.

Rescuing exceptions

What a rescue clause matches

A rescue clause names the classes or modules it handles. Ruby evaluates the list at the moment an exception reaches the clause and tests each entry with ===. For a class or module that is "the exception is an instance of this class or of a subclass" (Module#===). The consequences:

  • the clauses are tried in order and the first that matches handles the exception, and only once: an ArgumentError that matched an earlier clause is not offered to a later rescue StandardError;

  • put the specific classes first. A clause for a superclass above one for its subclass makes the second unreachable (RuboCop reports it as Lint/ShadowedException);

  • the list can be any expression that yields a class or module, including a splatted array, so a project can name its recoverable errors once;

  • an entry that is not a class or module raises TypeError ("class or module required for rescue clause") at the point the exception arrives, which hides the original error. RuboCop reports a literal such as rescue "boom" as Lint/RescueType;

  • a module with a custom self.=== works as a matcher, which is how a project can rescue "every retryable error" without an inheritance relationship.

# frozen_string_literal: true

require "shelf"

NETWORK_ERRORS = [IOError, SystemCallError].freeze

# rescue accepts a splatted list, so the set can be named and shared.
def probe(store)
  store.call
rescue *NETWORK_ERRORS => e
  "network: #{e.class}"
rescue Shelf::Error, KeyError => e
  "domain: #{e.class}"
end

puts probe(-> { raise IOError })
puts probe(-> { File.read("missing.txt") })
puts probe(-> { raise Shelf::Error })
puts probe(-> { {}.fetch(:isbn) })

# rescue matches with ===, so any object that implements === can be a matcher.
module Retryable
  def self.===(error) = error.respond_to?(:retryable?) && error.retryable?
end

FlakyError = Class.new(StandardError) do
  def retryable? = true
end

begin
  raise FlakyError, "try again"
rescue Retryable => e
  puts "retryable: #{e.message}"
end

# Anything that is not a class or module is a TypeError, raised only when
# an exception actually reaches that clause.
begin
  begin
    raise "boom"
  rescue "boom" # rubocop:disable Lint/RescueType
    puts "never reached"
  end
rescue TypeError => e
  puts e.message
end
network: IOError
network: Errno::ENOENT
domain: Shelf::Error
domain: KeyError
retryable: try again
class or module required for rescue clause

Capturing the exception with => name binds the object to a local variable that outlives the rescue clause. The same object is also available as the global $!, and its backtrace as $@, both only inside the clause. Prefer the local variable: $! is a thread-local global that changes when another exception is rescued in between.

Handlers without begin

The begin and end keywords can be dropped when the handler’s scope is already delimited. A method body, a do/end block (since Ruby 2.5), a lambda written with do/end, and class and module bodies all accept rescue, else and ensure directly. A brace block { ... } does not: a rescue in a brace block is a syntax error, so a block that needs a handler is written with do/end.

The rescue modifier (expression rescue fallback) rescues StandardError only and gives no way to name the class or see the exception. It hides every failure, including typos that raise NoMethodError, and RuboCop flags it as Style/RescueModifier. The example keeps one occurrence on purpose.

# frozen_string_literal: true

# rescue inside a do/end block (Ruby 2.5) handles the failure per element.
totals = ["978-0-306-40615-7", "oops", "978-1-86197-876-9"].map do |text|
  Integer(text.delete("-"))
rescue ArgumentError
  0
end
p totals

# The rescue modifier catches StandardError only and hides the cause.
count = Integer("12 books") rescue 0 # rubocop:disable Style/RescueModifier
p count

# A lambda with do/end accepts rescue too.
parse = lambda do |text|
  Float(text)
rescue ArgumentError, TypeError
  Float::NAN
end
p parse.call("3.5"), parse.call("n/a"), parse.call(nil)

# begin/rescue is an expression; its value is the last expression evaluated.
value =
  begin
    Integer("x")
  rescue ArgumentError
    -1
  else
    0
  ensure
    puts "ensure ran"
  end
p value
[9780306406157, 0, 9781861978769]
0
3.5
NaN
NaN
ensure ran
-1

A begin block is an expression. Its value is the value of the last statement of the body when nothing was raised and there is no else; the value of the else clause when there is one; the value of the rescue clause when an exception was handled. The ensure clause never changes the value (the last output above, -1, is the rescue result even though else and ensure exist).

else

else is for code that should run only when the body succeeded and whose own failures must not be handled by the sibling rescue clauses. Without it, the code would sit inside the body and an exception from it would be rescued by accident:

# frozen_string_literal: true

def import(text)
  value = Integer(text)
rescue ArgumentError
  puts "rescue: #{text.inspect} is not a number"
  0
else
  # Runs only when the body raised nothing. An exception raised here is NOT
  # seen by the rescue clauses above; it propagates to the caller.
  raise ArgumentError, "#{value} is out of range" if value > 100

  value
ensure
  puts "ensure: done with #{text.inspect}"
end

p import("42")
p import("x")

begin
  import("500")
rescue ArgumentError => e
  puts "caller got: #{e.message}"
end
ensure: done with "42"
42
rescue: "x" is not a number
ensure: done with "x"
0
ensure: done with "500"
caller got: 500 is out of range

ensure still runs after the exception from else leaves the method. Keep the body as small as possible: only the statement that can raise the exception you handle belongs between begin and rescue.

ensure

ensure is for cleanup that must happen whatever happens: closing a file, releasing a lock, restoring a setting. It runs after normal completion, after a rescued exception, when an exception is not handled at all, and when the body leaves through return, break, next, throw or exit. Three rules prevent surprises, shown in the next example:

  1. Its value is discarded; the handler’s value comes from the body, else or rescue.

  2. An explicit return (or break, next) in ensure cancels the exception in flight. The exception is silently lost. RuboCop reports it as Lint/EnsureReturn.

  3. An exception raised by the ensure code replaces the original, which survives as its cause.

Most ensure clauses written by hand are unnecessary: the block forms of File.open, Dir.chdir, Mutex#synchronize and Monitor#synchronize close or release in their own ensure.

# frozen_string_literal: true

require "English"

# 1. ensure runs, but its value is discarded.
def value_of_ensure
  :body
ensure
  puts "ensure ran"
end
p value_of_ensure

# 2. An explicit return inside ensure swallows the exception in flight.
def swallowed
  raise "lost"
ensure
  return :silently_returned # rubocop:disable Lint/EnsureReturn
end
p swallowed

# 3. An exception raised inside ensure replaces the original one,
#    which survives as the cause.
def replaced
  raise ArgumentError, "original"
ensure
  raise IOError, "cleanup failed" if $ERROR_INFO
end

begin
  replaced
rescue IOError => e
  puts "#{e.class}: #{e.message} (cause: #{e.cause.inspect})"
end

# 4. Block forms close resources without a hand-written ensure.
File.open(__FILE__) do |file|
  puts file.each_line.first.chomp
end
puts "closed after the block"

# 5. The same shape, written by hand.
file = File.open(__FILE__) # rubocop:disable Style/FileOpen
begin
  file.readline
ensure
  file.close
end
p file.closed?
ensure ran
:body
:silently_returned
IOError: cleanup failed (cause: #<ArgumentError: original>)
# frozen_string_literal: true
closed after the block
true

retry

retry restarts the begin body from its first statement. It is valid only inside a rescue clause; anywhere else it is a syntax error ("Invalid retry without rescue"). It is the building block for retrying a flaky operation. A retry loop needs three things: a limit, so that the failure eventually propagates; a delay that grows (exponential backoff, base * 2**(tries - 1), usually with random jitter so that many clients do not retry in lockstep); and a narrow class list, so that only transient errors are retried. The sleeper is injected so that the example runs instantly.

# frozen_string_literal: true

# A store that fails twice before it answers.
class FlakyStore
  def initialize(failures) = @failures = failures

  def all
    raise IOError, "connection reset" if (@failures -= 1) >= 0

    %w[book-1 book-2]
  end
end

def backoff(base, tries) = base * (2**(tries - 1))

def fetch_with_retry(store, attempts: 4, base_delay: 0.1, sleeper: method(:sleep))
  tries = 0
  begin
    tries += 1
    store.all
  rescue IOError => e
    raise if tries >= attempts

    puts "attempt #{tries} failed (#{e.message}); retrying"
    sleeper.call(backoff(base_delay, tries))
    retry
  end
end

delays = []
fake_sleep = ->(seconds) { delays << seconds }

p fetch_with_retry(FlakyStore.new(2), sleeper: fake_sleep)

begin
  fetch_with_retry(FlakyStore.new(10), attempts: 3, sleeper: fake_sleep)
rescue IOError => e
  puts "gave up: #{e.message}"
end
p delays
attempt 1 failed (connection reset); retrying
attempt 2 failed (connection reset); retrying
["book-1", "book-2"]
attempt 1 failed (connection reset); retrying
attempt 2 failed (connection reset); retrying
gave up: connection reset
[0.1, 0.2, 0.1, 0.2]
$ cat bad_retry.rb
def fetch
  retry
end
$ ruby -c bad_retry.rb
bad_retry.rb:2: syntax error found (SyntaxError)
  1 | def fetch
> 2 |   retry
    |   ^~~~~~ Invalid retry without rescue
  3 | end

To retry one iteration of a loop body (not a begin block), use redo, described in Control Expressions. Retry only operations that are safe to repeat (idempotent), and never retry a programming error such as NoMethodError: it will fail the same way every time.

Re-raising

A handler often needs to act and let the failure continue: log it, release something, add context. There are four ways to continue, and they differ in which object and which backtrace the caller sees:

Form Result

raise

The same object and the same backtrace. The right default for "log and pass on".

raise error

The same object; because it already has a backtrace, that is kept too.

raise error, "new message"

A copy made by exception, with the new message. The backtrace of the original is kept.

raise OtherError, "message"

A new exception, with the handled one as its cause. The backtrace starts at this line.

# frozen_string_literal: true

def load_config = raise(IOError, "config unreadable")

def attempt
  load_config
rescue IOError => e
  yield e
end

# 1. Bare raise: the same object, the same backtrace.
caught = nil
begin
  attempt do |e|
    caught = e
    raise
  end
rescue IOError => e
  puts "bare raise:       same object? #{e.equal?(caught)}, same backtrace? #{e.backtrace == caught.backtrace}"
end

# 2. raise e: also the same object, and the original backtrace is kept.
begin
  attempt do |e|
    caught = e
    raise e
  end
rescue IOError => e
  puts "raise e:          same object? #{e.equal?(caught)}, same backtrace? #{e.backtrace == caught.backtrace}"
end

# 3. raise e, "new message": a copy (via Exception#exception) with the new message.
begin
  attempt do |e|
    caught = e
    raise e, "config unreadable (retried twice)"
  end
rescue IOError => e
  puts "raise e, message: same object? #{e.equal?(caught)}, message #{e.message.inspect}"
end

# 4. A new class: a new exception whose cause is the one being handled.
begin
  attempt do |_e|
    raise ArgumentError, "invalid configuration"
  end
rescue ArgumentError => e
  puts "new class:        cause #{e.cause.inspect}"
end
bare raise:       same object? true, same backtrace? true
raise e:          same object? true, same backtrace? true
raise e, message: same object? false, message "config unreadable (retried twice)"
new class:        cause #<IOError: config unreadable>

Source: Kernel#raise

Do not log and re-raise at every level of a call chain: the same failure then appears once per level in the log. Handle an exception in one place, usually at the edge of the program (see Handling errors at the edge of a program), and let it pass through the layers in between.

The exception hierarchy

All exceptions descend from Exception. The branch matters because rescue selects by class and a bare rescue selects StandardError.

The Ruby exception class hierarchy. Exception has direct subclasses NoMemoryError, ScriptError with LoadError NotImplementedError and SyntaxError, SecurityError, SignalException with Interrupt, StandardError, SystemExit, SystemStackError and fatal. StandardError has ArgumentError, EncodingError, FiberError, IOError, IndexError, LocalJumpError, NameError, NoMatchingPatternError, RangeError, RegexpError, RuntimeError, SystemCallError, ThreadError, TypeError and ZeroDivisionError, each with the subclasses shown. A bare rescue handles only the StandardError branch.

The documented hierarchy is in Exception (the list there omits NoMatchingPatternError, which exists). This program prints the tree that the running interpreter actually has, using Class#subclasses (Ruby 3.1), before any library adds its own classes:

# frozen_string_literal: true

# Walks the exception classes that exist before any library is loaded.
def print_tree(klass, depth = 0)
  children = klass.subclasses.select { |c| c.name && !c.name.include?("::") }
  puts "#{'  ' * depth}#{klass}"
  children.sort_by(&:name).each { |child| print_tree(child, depth + 1) }
end

print_tree(Exception)

puts
puts "Errno children: #{Errno.constants.size}, e.g. #{Errno::ENOENT.ancestors.take(3).join(' < ')}"
Exception
  NoMemoryError
  ScriptError
    LoadError
    NotImplementedError
    SyntaxError
  SecurityError
  SignalException
    Interrupt
  StandardError
    ArgumentError
      UncaughtThrowError
    EncodingError
    FiberError
    IOError
      EOFError
    IndexError
      KeyError
      StopIteration
        ClosedQueueError
    LocalJumpError
    NameError
      NoMethodError
    NoMatchingPatternError
      NoMatchingPatternKeyError
    RangeError
      FloatDomainError
    RegexpError
    RuntimeError
      FrozenError
    SystemCallError
    ThreadError
    TypeError
    ZeroDivisionError
  SystemExit
  SystemStackError
  fatal

Errno children: 158, e.g. Errno::ENOENT < SystemCallError < StandardError

The branches have different meanings:

  • StandardError is the branch for errors a program may reasonably handle: bad arguments, missing keys, failed I/O, type errors. Application and library errors belong here, and rescue => e handles exactly this branch.

  • ScriptError (LoadError, NotImplementedError, SyntaxError) describes a problem with the program itself, for example a missing file passed to require. It is outside StandardError on purpose, so that a generic handler does not hide a broken installation.

  • SignalException and its subclass Interrupt (Ctrl-C) represent a signal delivered to the process.

  • SystemExit is how exit works: it is an exception, so that ensure clauses run, and a handler that catches it stops the program from exiting.

  • NoMemoryError, SystemStackError (infinite recursion) and SecurityError describe a process that cannot continue normally. The hidden class fatal is raised by the interpreter for situations such as a deadlock (every thread blocked); it cannot be rescued by name.

Why a bare rescue is StandardError and why never to rescue Exception

rescue without a class and rescue => e mean rescue StandardError. That is the line between "something my code can deal with" and "stop the world", and it is deliberate. rescue Exception crosses it:

  • it catches SystemExit, so exit stops working, and Interrupt, so Ctrl-C no longer interrupts the program;

  • it catches NoMemoryError, SystemStackError and ScriptError, none of which the handler can repair;

  • it catches Timeout::ExitException, the exception used by Timeout.timeout, and so breaks timeouts.

The next example shows which handler sees which exception, and that a rescue of SystemExit turns exit into an ordinary exception.

# frozen_string_literal: true

# Which handler sees which exception?
def classify(error)
  raise error
rescue => e # rubocop:disable Style/RescueStandardError
  "bare rescue catches #{e.class}"
rescue ScriptError, SignalException, SystemExit, NoMemoryError, SystemStackError => e
  "escapes bare rescue: #{e.class}"
end

[
  ZeroDivisionError.new, KeyError.new, Errno::ENOENT.new,
  NotImplementedError.new, LoadError.new,
  Interrupt.new, SystemExit.new,
  NoMemoryError.new, SystemStackError.new
].each { |error| puts classify(error) }

# exit is an exception: a broad rescue would swallow it.
begin
  exit 2
rescue SystemExit => e
  puts "exit became #{e.class}, status #{e.status}"
end
bare rescue catches ZeroDivisionError
bare rescue catches KeyError
bare rescue catches Errno::ENOENT
escapes bare rescue: NotImplementedError
escapes bare rescue: LoadError
escapes bare rescue: Interrupt
escapes bare rescue: SystemExit
escapes bare rescue: NoMemoryError
escapes bare rescue: SystemStackError
exit became SystemExit, status 2

Source: StandardError

The legitimate uses of rescue Exception are narrow: a top-level handler that records a fatal failure and then re-raises with a bare raise, or a thread or fiber supervisor that must observe every way its child ended. Even then, ensure is usually what you want. RuboCop’s Lint/RescueException flags the rest, and Style/RescueStandardError (default explicit) asks you to write rescue StandardError instead of a bare rescue so that the choice is visible; the examples here follow that cop except where a bare rescue is the point.

The classes you will meet

The table lists the exceptions raised most often by the core library, the method that raises each one, and the message Ruby 4.0 produces. The program after the table triggers each of them and prints the real class and message.

Class Typical cause Useful accessors

NoMethodError

A method that does not exist, most often on nil.

name, args, receiver

NameError

An unknown constant, local variable or method name. NoMethodError is a subclass.

name, receiver

ArgumentError

Wrong number of arguments, a missing or unknown keyword, an invalid value (Integer("x"), comparison of unlike objects).

none

TypeError

A value of the wrong type (1 + nil, [1] + 1).

none

ZeroDivisionError

Integer division or modulo by zero.

none

KeyError

Hash#fetch or ENV.fetch with a missing key.

key, receiver

IndexError

Array#fetch outside the bounds. KeyError and StopIteration are subclasses.

none

StopIteration

Enumerator#next after the last element; ends a loop.

result

FrozenError

Modifying a frozen object, here the frozen Shelf::Loan::STATES array.

receiver

RangeError

A number outside the accepted range, such as 300.chr. FloatDomainError is a subclass.

none

Errno::ENOENT and the other Errno::* classes

A failed system call; a subclass of SystemCallError.

errno

IOError, EOFError

Reading from a closed stream; reading past the end of a stream.

none

NotImplementedError

An abstract method, or a platform feature that is missing. A ScriptError.

none

LocalJumpError

A return from a proc whose method has already returned.

reason, exit_value

FiberError, ThreadError, ClosedQueueError

Resuming a finished fiber; locking a mutex twice; pushing to a closed queue.

none

RegexpError

An invalid regular expression.

none

Encoding::UndefinedConversionError and Encoding::*Error

A character that has no representation in the target encoding.

error_char, source_encoding_name

NoMatchingPatternError, NoMatchingPatternKeyError

A case/in without else, or a rightward => pattern, that does not match. The second one is raised when a key is missing.

key (second)

UncaughtThrowError

A throw without a catch.

tag, value

SystemStackError

Unbounded recursion.

none

Timeout::Error

A Timeout.timeout block that ran out of time (needs require "timeout").

none

# frozen_string_literal: true

require "stringio"
require "shelf"

class Abstract
  def area = raise(NotImplementedError, "#{self.class} must implement #{__method__}")
end

def recurse(depth) = recurse(depth + 1)

def callback = proc { return 1 }

dead_fiber = Fiber.new { :done }.tap(&:resume)
mutex = Thread::Mutex.new
closed = Thread::Queue.new.tap(&:close)

samples = {
  "NoMethodError" => -> { nil.upcase },
  "NameError (constant)" => -> { Object.const_get(:Missing) },
  "NameError (local)" => -> { undefined_name },
  "ArgumentError (keywords)" => -> { Shelf::Book.new },
  "ArgumentError (value)" => -> { Shelf::Isbn.new("123") },
  "ArgumentError (compare)" => -> { [3, "a"].sort },
  "ArgumentError (lambda)" => -> { ->(x) { x }.call },
  "TypeError (coerce)" => -> { 1 + nil },
  "TypeError (implicit)" => -> { [1] + 1 },
  "ZeroDivisionError" => -> { 1 / 0 },
  "KeyError" => -> { { isbn: 1 }.fetch(:title) },
  "IndexError" => -> { [1, 2].fetch(5) },
  "FrozenError" => -> { Shelf::Loan::STATES << :lost },
  "StopIteration" => -> { [].each.next },
  "RangeError" => -> { 300.chr },
  "FloatDomainError" => -> { Float::NAN.to_i },
  "Math::DomainError" => -> { Math.sqrt(-1) },
  "Errno::ENOENT" => -> { File.read("nope.txt") },
  "IOError" => -> { File.open(__FILE__, &:itself).read },
  "EOFError" => -> { StringIO.new.readline },
  "NotImplementedError" => -> { Abstract.new.area },
  "LocalJumpError" => -> { callback.call },
  "FiberError" => -> { dead_fiber.resume },
  "ThreadError" => -> { mutex.lock.lock },
  "ClosedQueueError" => -> { closed.push(1) },
  "RegexpError" => -> { Regexp.new("(") },
  "Encoding::UndefinedConversionError" => -> { "é".encode("US-ASCII") },
  "UncaughtThrowError" => -> { throw :nowhere },
  "NoMatchingPatternError" => -> { Integer("7") => String },
  "SystemStackError" => -> { recurse(0) }
}

samples.each do |label, sample|
  sample.call
rescue StandardError, SystemStackError => e
  puts "#{label}: #{e.class}: #{e.message.lines.first.strip}"
end
NoMethodError: NoMethodError: undefined method 'upcase' for nil
NameError (constant): NameError: uninitialized constant Missing
NameError (local): NameError: undefined local variable or method 'undefined_name' for main
ArgumentError (keywords): ArgumentError: missing keywords: :isbn, :title, :author
ArgumentError (value): ArgumentError: invalid ISBN: 123
ArgumentError (compare): ArgumentError: comparison of Integer with String failed
ArgumentError (lambda): ArgumentError: wrong number of arguments (given 0, expected 1)
TypeError (coerce): TypeError: nil can't be coerced into Integer
TypeError (implicit): TypeError: no implicit conversion of Integer into Array
ZeroDivisionError: ZeroDivisionError: divided by 0
KeyError: KeyError: key not found: :title
IndexError: IndexError: index 5 outside of array bounds: -2...2
FrozenError: FrozenError: can't modify frozen Array: [:available, :lent, :overdue, :returned]
StopIteration: StopIteration: iteration reached an end
RangeError: RangeError: 300 out of char range
FloatDomainError: FloatDomainError: NaN
Math::DomainError: Math::DomainError: Numerical argument is out of domain - sqrt
Errno::ENOENT: Errno::ENOENT: No such file or directory @ rb_sysopen - nope.txt
IOError: IOError: closed stream
EOFError: EOFError: end of file reached
builtin_errors.rb:7:in 'Abstract#area': Abstract must implement area (NotImplementedError)
	from builtin_errors.rb:39:in 'block in <main>'
	from builtin_errors.rb:52:in 'block in <main>'
	from builtin_errors.rb:51:in 'Hash#each'
	from builtin_errors.rb:51:in '<main>'

Two details are visible in the output. The message of NoMethodError names the receiver’s class (for an instance of Shelf::Isbn, Ruby 3.3) or the value itself for nil, true, false and classes (for nil), and the quote around the method name is a plain apostrophe (3.4); examples written for older versions show undefined method `x' for nil:NilClass. And the ArgumentError for a missing keyword of a Data class lists every missing name at once.

System errors and errno

Failed operating-system calls raise a subclass of SystemCallError, one Errno:: class per error number (Errno::ENOENT, Errno::EACCES, Errno::EEXIST, Errno::EPIPE, Errno::ECONNREFUSED and so on). Each class has an Errno constant with the number, instances answer errno, and SystemCallError.new(number) builds the matching subclass. Rescue the specific class for the case you can repair (a missing file) and the superclass for the rest.

# frozen_string_literal: true

begin
  File.read("config.yml")
rescue Errno::EACCES, Errno::ENOENT => e
  puts "#{e.class} errno=#{e.errno}: #{e.message}"
end

# Errno::* classes are subclasses of SystemCallError, one per errno value.
p Errno::ENOENT::Errno
p Errno::ENOENT.ancestors.take(3)
p Errno::ENOENT.new("config.yml").message
p SystemCallError.new(2).class
p SystemCallError.new("custom context", 13).message
Errno::ENOENT errno=2: No such file or directory @ rb_sysopen - config.yml
2
[Errno::ENOENT, SystemCallError, StandardError]
"No such file or directory - config.yml"
Errno::ENOENT
"Permission denied - custom context"

Source: Errno

Source: SystemCallError

Custom error classes

A library or application defines its own errors for two reasons: callers can rescue exactly the failures of that library, and the class name documents the failure. The conventions that Ruby code follows:

  • One root class per library, a subclass of StandardError: Shelf::Error here, JSON::JSONError, Gem::LoadError. Callers who want "anything from this library" rescue the root; the library never raises a bare RuntimeError for its own failures.

  • A hierarchy that mirrors how callers will react. Shelf::AlreadyLentError < Shelf::LoanError < Shelf::Error lets a caller handle every loan problem together or one kind specifically.

  • Names end in Error and describe the condition (NotFoundError), not the place it was detected.

  • Subclass StandardError (or a more specific built-in such as ArgumentError when the condition really is a bad argument), never Exception. A subclass of Exception escapes every ordinary handler.

  • Carry the data a handler needs as readers (isbn, book), not only inside the message text. Callers should not have to parse a message.

  • Call super with the message, so that message, inspect, full_message and the uncaught-exception printer work without further code. Give the constructor a default message so that raise Shelf::NotFoundError, isbn reads well.

  • Do not override message; override to_s if you must change the text (see below).

# frozen_string_literal: true

require "shelf"

module Shelf
  # Shelf::Error (defined in lib/shelf.rb) is the root every caller can rescue.
  class NotFoundError < Error
    attr_reader :isbn

    def initialize(isbn, message = "no book with ISBN #{isbn}")
      @isbn = isbn
      super(message)
    end
  end

  class LoanError < Error; end

  class AlreadyLentError < LoanError
    attr_reader :book

    def initialize(book)
      @book = book
      super("#{book.title} is already lent")
    end
  end
end

author = Shelf::Author.new("A. Writer")
isbn = Shelf::Isbn.new("978-0-306-40615-7")
book = Shelf::Book.new(isbn, "Ruby Basics", author)
lent = { isbn.digits => book }

def lend(lent, digits)
  book = lent.fetch(digits) { raise Shelf::NotFoundError, digits }
  raise Shelf::AlreadyLentError, book if book.title.start_with?("Ruby")

  book
end

%w[9780306406157 0000000000000].each do |digits|
  lend(lent, digits)
rescue Shelf::LoanError => e
  puts "loan problem: #{e.message} by #{e.book.author.name}"
rescue Shelf::Error => e
  puts "library problem: #{e.message} [#{e.class}]"
end

p Shelf::AlreadyLentError.ancestors.take(4)
p Shelf::NotFoundError.new("123").message
loan problem: Ruby Basics is already lent by A. Writer
library problem: no book with ISBN 0000000000000 [Shelf::NotFoundError]
[Shelf::AlreadyLentError, Shelf::LoanError, Shelf::Error, StandardError]
"no book with ISBN 123"

Source: Exception

A caller that wants to react to loan problems rescues Shelf::LoanError; the second rescue clause shows a library-wide fallback that still sees NotFoundError. A single rescue Shelf::Error is the equivalent of the "errors of this gem" boundary that a service object or command-line program needs.

message, to_s and inspect

Exception#message calls to_s, inspect is #<Class: to_s>, and detailed_message is message plus the class name. Overriding to_s therefore changes all three, while overriding message changes only message and detailed_message. Prefer to_s, or pass the final text to super.

# frozen_string_literal: true

# Overriding #to_s changes message, inspect and detailed_message together.
class ByToS < StandardError
  def to_s = "from to_s"
end

# Overriding #message changes only message and detailed_message.
ByMessage = Class.new(StandardError) do
  def message = "from message"
end

[ByToS, ByMessage].each do |klass|
  error = klass.new("given to new")
  puts klass
  puts "  to_s:             #{error.to_s.inspect}"
  puts "  message:          #{error.message.inspect}"
  puts "  inspect:          #{error.inspect}"
  puts "  detailed_message: #{error.detailed_message(highlight: false).inspect}"
end
ByToS
  to_s:             "from to_s"
  message:          "from to_s"
  inspect:          #<ByToS: from to_s>
  detailed_message: "from to_s (ByToS)"
ByMessage
  to_s:             "given to new"
  message:          "from message"
  inspect:          #<ByMessage: given to new>
  detailed_message: "from message (ByMessage)"

Generated error classes and richer messages

Error classes can be built with Class.new(parent), which is useful when many near-identical classes are needed. The class gets its name when it is assigned to a constant (const_set). The same example overrides detailed_message (Ruby 3.2) to list several problems under one error. The override must accept and forward keyword arguments, because Ruby and its gems pass highlight:, did_you_mean:, error_highlight: and syntax_suggest::

# frozen_string_literal: true

require "shelf"

module Shelf
  # One subclass per missing field, generated instead of written three times.
  %w[Title Author Isbn].each do |field|
    const_set(
      "Missing#{field}Error",
      Class.new(Error) do
        define_method(:initialize) do |message = "#{field.downcase} is required"|
          super(message)
        end
      end
    )
  end

  # An error that carries a list of problems and prints them.
  class ValidationError < Error
    attr_reader :problems

    def initialize(problems)
      @problems = problems.freeze
      super("#{problems.size} problems found")
    end

    # Keep the signature open: callers pass highlight:, did_you_mean: ...
    def detailed_message(highlight: false, **)
      [super, *problems.map { |problem| "  - #{problem}" }].join("\n")
    end
  end
end

p Shelf::MissingTitleError.new
p Shelf::MissingIsbnError.new("ISBN missing").message
p Shelf::MissingAuthorError.ancestors.first(3)

begin
  raise Shelf::ValidationError, ["title is blank", "ISBN has 3 digits"]
rescue Shelf::Error => e
  puts e.message
  puts e.detailed_message
  puts e.full_message(highlight: false)
end
#<Shelf::MissingTitleError: title is required>
"ISBN missing"
[Shelf::MissingAuthorError, Shelf::Error, StandardError]
2 problems found
2 problems found (Shelf::ValidationError)
  - title is blank
  - ISBN has 3 digits
error_details.rb:39:in '<main>': 2 problems found (Shelf::ValidationError)
  - title is blank
  - ISBN has 3 digits

Abstract methods: do not use NotImplementedError for them

Older code and tutorials signal "subclasses must implement this" with raise NotImplementedError. The name suggests it, but NotImplementedError is a ScriptError and is reserved for "this platform does not support the call" (it is what fork raises on Windows). A bare rescue, and so most test helpers and application error handlers, does not see it. Raise a NoMethodError subclass, or a project error, for abstract methods:

# frozen_string_literal: true

module Shelf
  # An abstract store. Subclasses must implement #all and #add.
  class AbstractStore
    def all = raise(NotImplementedError, "#{self.class} must implement #{__method__}")
    def add(_book) = raise(NoMethodError, "#{self.class}#add is not implemented")
  end
end

store = Shelf::AbstractStore.new

# NotImplementedError is a ScriptError, so a rescue of StandardError (what a
# bare rescue means) does not see it.
begin
  begin
    store.all
  rescue StandardError => e
    puts "bare rescue caught #{e.class}"
  end
rescue NotImplementedError => e
  puts "only an explicit rescue caught #{e.class} (a #{e.class.superclass})"
end

# A StandardError subclass is rescued by ordinary handlers.
begin
  store.add(nil)
rescue StandardError => e
  puts "bare rescue caught #{e.class}: #{e.message}"
end
only an explicit rescue caught NotImplementedError (a ScriptError)
bare rescue caught NoMethodError: Shelf::AbstractStore#add is not implemented

Source: ScriptError

Messages, backtraces and diagnostics

An exception carries up to four pieces of information: its class, a message, a backtrace and a cause. Exception has one accessor or formatter for each:

Method Returns

message, to_s

The message string given to new (or the class name if none was given).

inspect

#<Class: message>. A message with line breaks is shown as an escaped string, and an empty message shows only the class name.

detailed_message(highlight: false, **kwargs)

The message plus the class name, plus anything a gem adds (the error_highlight snippet, did_you_mean suggestions). It is what the default error printer uses (Ruby 3.2).

full_message(highlight:, order: :top)

The complete report: location, detailed message, backtrace and the cause chain. order: :bottom prints the innermost frame last, like older Ruby versions.

backtrace

An array of strings, or nil for an exception that was never raised.

backtrace_locations

The same frames as Thread::Backtrace::Location objects with path, absolute_path, lineno, label and base_label; nil when the backtrace was set from strings.

set_backtrace(value)

Replaces the backtrace. Accepts an array of Location objects (Ruby 3.4), an array of strings, a string or nil.

cause

The exception that was being handled when this one was raised (Ruby 2.1), or nil.

exception(message = nil)

self, or a copy with a new message.

Exception.to_tty?

true when standard error is a terminal; the default for the highlight: keyword of full_message.

Pass highlight: explicitly whenever the text is not going straight to a terminal (a log file, a test assertion, an HTML page): with highlight: true the string contains ANSI escape sequences for bold and underline.

# frozen_string_literal: true

def parse(text)
  raise ArgumentError, "invalid ISBN: #{text}" unless text.length == 13

  text
end

def import(text) = parse(text)

begin
  import("978")
rescue ArgumentError => e
  puts "message:             #{e.message.inspect}"
  puts "to_s:               #{e.to_s.inspect}"
  puts "inspect:            #{e.inspect}"
  puts "detailed_message:   #{e.detailed_message(highlight: false).inspect}"
  puts "highlighted:        #{e.detailed_message(highlight: true).inspect}"
  puts "backtrace (strings):"
  e.backtrace.each { puts "  #{it}" }
  loc = e.backtrace_locations.first
  puts "first location:     #{loc.path}:#{loc.lineno} label=#{loc.label.inspect} base_label=#{loc.base_label.inspect}"
  puts "full_message:"
  puts e.full_message(highlight: false)
  puts "full_message (bottom first):"
  puts e.full_message(highlight: false, order: :bottom)
  puts "to_tty?: #{Exception.to_tty?}"
end

multi = RuntimeError.new("line one\nline two")
puts multi.inspect
puts RuntimeError.new.inspect
puts RuntimeError.new("").inspect
p RuntimeError.new(:a_symbol).message
message:             "invalid ISBN: 978"
to_s:               "invalid ISBN: 978"
inspect:            #<ArgumentError: invalid ISBN: 978>
detailed_message:   "invalid ISBN: 978 (ArgumentError)\n\n  raise ArgumentError, \"invalid ISBN: \#{text}\" unless text.length == 13\n        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^"
highlighted:        "\e[1minvalid ISBN: 978 (\e[1;4mArgumentError\e[m\e[1m)\e[m\n\n\e[1m  raise ArgumentError, \"invalid ISBN: \#{text}\" unless text.length == 13\e[m\n\e[1m        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^\e[m"
backtrace (strings):
  messages.rb:4:in 'Object#parse'
  messages.rb:9:in 'Object#import'
  messages.rb:12:in '<main>'
first location:     messages.rb:4 label="Object#parse" base_label="parse"
full_message:
messages.rb:4:in 'Object#parse': invalid ISBN: 978 (ArgumentError)

  raise ArgumentError, "invalid ISBN: #{text}" unless text.length == 13
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
	from messages.rb:9:in 'Object#import'
	from messages.rb:12:in '<main>'
full_message (bottom first):
Traceback (most recent call last):
	2: from messages.rb:12:in '<main>'
	1: from messages.rb:9:in 'Object#import'
messages.rb:4:in 'Object#parse': invalid ISBN: 978 (ArgumentError)

  raise ArgumentError, "invalid ISBN: #{text}" unless text.length == 13
        ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
to_tty?: false
#<RuntimeError:"line one\nline two">
#<RuntimeError: RuntimeError>
RuntimeError
"a_symbol"

The frame labels in the backtrace changed in Ruby 3.4: the opening quote is an apostrophe (it was a backtick) and a method label includes its class when the class has a permanent name, 'Object#parse' and 'Shelf::Isbn.new' instead of parse and new. Code that parses backtrace strings with a regular expression has to accept both forms; code that reads Location#path, lineno and base_label is unaffected. Since Ruby 3.4 the frames of internal rescue and ensure sections no longer appear, and since 4.0 methods implemented in Ruby inside the core library (such as Integer() or Array#fetch_values) are reported as if they were native, with the caller’s file and line.

error_highlight and did_you_mean

Two default gems improve the messages of uncaught errors, and of any exception whose detailed_message is printed. error_highlight (Ruby 3.1) reads the source file and underlines the part of the line that failed: the method name in NoMethodError, the constant in NameError, the offending operand in TypeError and ArgumentError. Since Ruby 4.0, an ArgumentError about the number of arguments shows two snippets, the call site (caller) and the method definition (callee). did_you_mean adds suggestions for misspelled method names, constants, variables, hash keys (KeyError) and require paths. Both work through detailed_message, which is why an overriding method must forward its keywords. error_highlight needs the source file of the failing line, so code passed with -e gets the plain message.

# frozen_string_literal: true

require "shelf"

isbn = Shelf::Isbn.new("978-0-306-40615-7")

begin
  isbn.digit.upcase
rescue NoMethodError => e
  puts e.detailed_message(highlight: false)
end

catalog = Shelf::Catalog.new
begin
  puts catalog.first.author.name
rescue NoMethodError => e
  puts e.detailed_message(highlight: false)
end

def lend(book, days) = "#{book} for #{days} days"

begin
  lend("Ruby Basics")
rescue ArgumentError => e
  puts e.detailed_message(highlight: false)
end

puts Shelf::Catalg
undefined method 'digit' for an instance of Shelf::Isbn (NoMethodError)

  isbn.digit.upcase
      ^^^^^^
Did you mean?  digits
undefined method 'author' for nil (NoMethodError)

  puts catalog.first.author.name
                    ^^^^^^^
wrong number of arguments (given 1, expected 2) (ArgumentError)

    caller: highlight_demo.rb:23
    |   lend("Ruby Basics")
        ^^^^
    callee: highlight_demo.rb:20
    | def lend(book, days) = "#{book} for #{days} days"
          ^^^^
highlight_demo.rb:28:in '<main>': uninitialized constant Shelf::Catalg (NameError)

puts Shelf::Catalg
          ^^^^^^^^
Did you mean?  Shelf::Catalog

Source: ErrorHighlight

Source: DidYouMean

They can be switched off for a run with --disable-error_highlight and --disable-did_you_mean (--disable-gems removes both as well). With both flags off, the final uncaught error prints just the message and the backtrace.

Other command-line options that help with long failures are --backtrace-limit=N, which truncates the printed backtrace (Ruby 3.0, Thread::Backtrace.limit reads it back), and -d (set $DEBUG to true), which prints a line to standard error every time an exception is raised, handled or not, and is noisy because Ruby’s own libraries raise and rescue internally.

For a script that fails deep in a call chain:

# frozen_string_literal: true

def level3 = Integer("seven")

def level2 = level3

def level1 = level2

begin
  level1
rescue ArgumentError
  puts "handled"
end

level1
$ ruby --backtrace-limit=1 deep_failure.rb
handled
deep_failure.rb:3:in 'Kernel#Integer': invalid value for Integer(): "seven" (ArgumentError)

def level3 = Integer("seven")
                     ^^^^^^^
	from deep_failure.rb:3:in 'Object#level3'
	 ... 3 levels...

Watching exceptions as they happen

TracePoint (see Reflection, Introspection and Tracing) has a :raise event, fired when an exception is raised, and a :rescue event (Ruby 3.3, Ruby-level rescue only), fired when one is handled. It is the right tool for "who raises this and who swallows it" in a codebase that handles too much. tp.raised_exception is the exception object and tp.lineno and tp.path the location. The event is also fired for exceptions that Ruby’s own code raises and rescues internally, so filter on tp.path in real use.

# frozen_string_literal: true

def parse(text)
  raise ArgumentError, "not a number: #{text}" unless text.match?(/\A\d+\z/)

  text.to_i
end

def total(values) = values.sum { |text| parse(text) }

events = []
trace = TracePoint.new(:raise, :rescue) do |tp|
  events << [tp.event, tp.raised_exception.class, tp.lineno]
end

trace.enable do
  total(%w[1 two 3])
rescue ArgumentError
  events << :handled
end

events.each { |event| p event }
[:raise, ArgumentError, 4]
[:rescue, ArgumentError, 19]
:handled

Source: TracePoint

Warnings

Not every problem is an exception. warn writes to standard error and Warning controls which categories of interpreter warnings are shown:

  • Warning[:deprecated] is off by default (since Ruby 2.7.2), Warning[:experimental] is on, Warning[:performance] is off. Warning.categories lists the categories: :deprecated, :experimental, :performance and :strict_unused_block (the last two since 3.3 and 3.4).

  • ruby -w and ruby -W:deprecated (or -W:no-experimental) set them from the command line, and $VERBOSE set to nil silences warn as well.

  • warn "message", uplevel: 1, category: :deprecated reports the line of the caller (the uplevel keyword) and respects the category switch. This is the pattern for deprecating your own API. The example below is run without -w, which would switch the deprecated category on from the start.

  • Warning.warn is the single hook through which every warning passes. Prepending a module to its singleton class captures or reroutes them, for example into a logger or into a test failure (Ruby 3.0 added the category: keyword).

# frozen_string_literal: true

p Warning.categories
p Warning[:deprecated], Warning[:experimental], Warning[:performance]

def old_api
  warn "old_api is deprecated, use new_api", uplevel: 1, category: :deprecated
  :result
end

# Deprecation warnings are off by default; enabling the category shows them.
old_api
Warning[:deprecated] = true
old_api

# Route warnings somewhere else by overriding Warning.warn.
captured = []
Warning.singleton_class.prepend(Module.new do
  define_method(:warn) do |message, category: nil|
    captured << [category, message.chomp]
    nil
  end
end)

warn "captured by the hook"
old_api
cleaned = captured.map { |category, message| [category, message.sub(/\A.*?: warning: /, "")] }
p cleaned
[:deprecated, :experimental, :performance, :strict_unused_block]
false
true
false
warnings_demo.rb:14: warning: old_api is deprecated, use new_api
[[nil, "captured by the hook"], [:deprecated, "old_api is deprecated, use new_api"]]

Source: Kernel#warn

Source: Warning

Wrapping and chaining

When a low-level error crosses a layer boundary, wrap it in the error type of the layer it enters, so callers depend on the library’s errors and not on its implementation (Errno::ENOENT or a JSON::ParserError), and keep the original as the cause. Ruby does the second part for you: an exception raised inside a rescue clause automatically gets the exception being handled ($!) as its cause, since Ruby 2.1. Set cause: explicitly to attach a different one, or cause: nil to hide the implicit one when the original adds nothing.

The uncaught-exception printer and full_message show the whole chain, innermost cause last, as the second half of this example demonstrates (the script ends with an uncaught exception, so the printed report is the real standard error output):

# frozen_string_literal: true

require "shelf"

module Shelf
  class StoreError < Error; end

  # Wraps whatever the storage layer raises, keeping the original as cause.
  class FileStore
    def initialize(path) = @path = path

    def load
      File.read(@path)
    rescue SystemCallError
      raise StoreError, "cannot load catalogue from #{@path}"
    end
  end
end

store = Shelf::FileStore.new("catalogue.json")

begin
  store.load
rescue Shelf::StoreError => e
  puts "error:   #{e.message}"
  puts "cause:   #{e.cause.inspect}"
  chain = [e]
  chain << chain.last.cause while chain.last.cause
  puts "chain:   #{chain.map(&:class).join(' <- ')}"
end

# Uncaught, the whole chain is printed.
store.load
error:   cannot load catalogue from catalogue.json
cause:   #<Errno::ENOENT: No such file or directory @ rb_sysopen - catalogue.json>
chain:   Shelf::StoreError <- Errno::ENOENT
wrap_cause.rb:15:in 'Shelf::FileStore#load': cannot load catalogue from catalogue.json (Shelf::StoreError)
	from wrap_cause.rb:33:in '<main>'
wrap_cause.rb:13:in 'IO.read': No such file or directory @ rb_sysopen - catalogue.json (Errno::ENOENT)
	from wrap_cause.rb:13:in 'Shelf::FileStore#load'
	from wrap_cause.rb:33:in '<main>'

Source: Exception#cause

detailed_message shows only the exception itself; use full_message or walk cause to include the chain. A cause is a link in a singly linked list, so a loop such as chain << chain.last.cause while chain.last.cause is enough to collect it. Wrap deliberately: a wrapper that discards the original message (raise Shelf::StoreError) forces the reader to find the cause in a different place, so the wrapper’s message should say what the layer was doing, and the cause says why it failed.

Exceptions and control flow

An exception is the wrong tool for something that happens in normal operation. A missing hash key, a number that may not parse, a search that finds nothing: these have return values. Ruby gives most of the core methods both forms, so choose by whether the failure is expected:

When absence or failure is expected When it is a bug or an environmental fault

Integer(text, exception: false), Float(text, exception: false) return nil.

Integer(text) raises ArgumentError.

hash.fetch(key, default), hash.fetch(key) { block }, hash[key].

hash.fetch(key) raises KeyError.

array.find { } returns nil; find(ifnone) computes a default.

A method that must find something and raises when it cannot, conventionally named with ! or fetch.

The safe navigation operator &., Hash#dig, Array#first.

NoMethodError on nil.

catch/throw to leave nested loops.

raise for a real failure.

catch and throw (with break, next, return and redo, in Control Expressions) implement non-local exits that are not errors. throw tag, value unwinds the stack to the nearest catch(tag) with the same tag (compared by identity; symbols are conventional) and makes catch return the value. Without a matching catch, throw raises UncaughtThrowError, a subclass of ArgumentError that carries tag and value. loop rescues StopIteration for you and returns the iterator’s result, which is what makes loop { enum.next } a safe way to walk an enumerator.

# frozen_string_literal: true

# 1. Expected absence is a value, not an exception.
p Integer("12", exception: false)
p Integer("twelve", exception: false)
p Float("1.5x", exception: false)
catalog = { "9780306406157" => "Ruby Basics" }
p catalog.fetch("0000000000000", "unknown")
p catalog.fetch("0000000000000") { |isbn| "no title for #{isbn}" }
begin
  catalog.fetch("0000000000000")
rescue KeyError => e
  puts "#{e.message} (key: #{e.key.inspect})"
end

# 2. catch/throw leaves nested loops without an exception class of its own.
shelves = [%w[a b c], %w[d target f], %w[g h i]]
found = catch(:found) do
  shelves.each_with_index do |shelf, row|
    shelf.each_with_index do |title, col|
      throw :found, [row, col] if title == "target"
    end
  end
  nil
end
p found

# throw without a matching catch is an UncaughtThrowError (an ArgumentError).
begin
  throw :missing, 42
rescue UncaughtThrowError => e
  puts "#{e.message}; tag=#{e.tag.inspect} value=#{e.value}"
end

# 3. loop rescues StopIteration and returns the enumerator's result.
numbers = [10, 20].each
result = loop do
  puts numbers.next
end
p result

# 4. Searching: find returns nil, find with an ifnone lambda computes a default.
titles = ["Ruby Basics", "Rust Basics"]
missing = titles.find { it.start_with?("Go") }
fallback = titles.find(-> { "none" }) { it.start_with?("Go") }
p missing, fallback
12
nil
nil
"unknown"
"no title for 0000000000000"
key not found: "0000000000000" (key: "0000000000000")
[1, 1]
uncaught throw :missing; tag=:missing value=42
10
20
[10, 20]
nil
"none"

Source: Kernel#catch

Two further reasons not to use exceptions for ordinary branching: creating one captures a backtrace, which costs far more than returning a value, and it makes the happy path unreadable because the reader must look for the rescue far from the call. Lint/SuppressedExceptionInNumberConversion goes after the most common abuse, Integer(text) rescue 0.

Fail fast or recover

The decision a handler makes, in order:

  1. Can this code repair the situation? A missing optional file can fall back to defaults; a transient network failure can be retried with backoff. Then rescue the specific class, repair, and continue.

  2. Can it add context and pass it on? Wrap into the layer’s own error with cause, or add a message with raise error, "more context", and let the caller decide.

  3. Neither? Do not rescue. Let the exception travel to the one place at the edge of the program that reports and exits.

Failing fast means raising at the first sign of an invalid state (a precondition on an argument, an invariant of a value object) rather than letting a nil travel three layers and fail with a confusing NoMethodError. Shelf::Isbn.new does this: an invalid ISBN never becomes an object.

Timeouts

Timeout.timeout(seconds) { ... } from the timeout default gem (require "timeout") interrupts the block when the time is up. It is simple, and its limits matter:

  • The interruption is an exception injected into the thread at an arbitrary point. Inside the block, Ruby code that is running at that moment is interrupted wherever it happens to be, including in the middle of updating a data structure.

  • To stop the block’s own rescue => e from swallowing the timeout, Ruby raises Timeout::ExitException, an Exception, inside the block and converts it into Timeout::Error (a RuntimeError) at the Timeout.timeout call. If you pass your own class as the second argument (Timeout.timeout(5, Shelf::StoreError, "store too slow")), that class is raised inside the block as is, and a rescue StandardError in the block can catch it.

  • ensure clauses in the block run, and can themselves be interrupted. The documentation recommends putting the Timeout.timeout call inside a begin/ensure rather than the other way round, and Thread.handle_interrupt for the hard cases.

  • A block can defeat the timeout by rescuing the exception or by never reaching a point where the interruption is delivered, so the method "cannot be relied on to enforce timeouts for untrusted blocks".

  • 0 and nil mean "no limit"; a negative number is an ArgumentError (Ruby 3.4).

# frozen_string_literal: true

require "timeout"

class SlowStoreError < StandardError; end

# Finishes in time: returns the block's value.
p Timeout.timeout(1) { :fast }

# Expires: Timeout::Error, a RuntimeError, raised where the call started.
begin
  Timeout.timeout(0.05) { sleep }
rescue Timeout::Error => e
  puts "#{e.class}: #{e.message}"
  p Timeout::Error.ancestors.take(3)
end

# The exception that interrupts the block is Timeout::ExitException, an
# Exception, so the block's own `rescue => e` cannot swallow it.
begin
  Timeout.timeout(0.05) do
    sleep
  rescue StandardError
    puts "never printed"
  ensure
    puts "ensure still runs"
  end
rescue Timeout::Error => e
  puts "outside: #{e.class}"
end

# A custom class and message are raised at the interrupted point instead.
begin
  Timeout.timeout(0.05, SlowStoreError, "store too slow") { sleep }
rescue SlowStoreError => e
  puts "#{e.class}: #{e.message}"
end

# nil and 0 mean no limit; a negative limit is rejected.
p Timeout.timeout(nil) { :unlimited }
begin
  Timeout.timeout(-1) { :never }
rescue ArgumentError => e
  puts e.message
end

# The reliable alternative is a timeout on the operation itself.
r, = IO.pipe
r.timeout = 0.05
begin
  r.read(1)
rescue IO::TimeoutError => e
  puts "#{e.class}: #{e.message}"
end
:fast
Timeout::Error: execution expired
[Timeout::Error, RuntimeError, StandardError]
ensure still runs
outside: Timeout::Error
SlowStoreError: store too slow
:unlimited
Timeout sec must be a non-negative number
IO::TimeoutError: Blocking operation timed out!

Source: Timeout.timeout

Source: Timeout, IO#timeout=

Where the operation itself offers a timeout, use that instead: it fails at a well-defined point and without interrupting arbitrary code. IO#timeout= makes blocking reads and writes raise IO::TimeoutError (Ruby 3.2), Thread::Queue#pop(timeout:) returns nil (3.2), Regexp.timeout= bounds a match (3.2), Socket.tcp and TCPSocket.new accept open_timeout: and raise IO::TimeoutError consistently (4.0), and Net::HTTP has open_timeout, read_timeout and write_timeout (see Networking and HTTP). A fiber scheduler can implement timeout_after, which makes Timeout.timeout non-blocking (3.1).

Exceptions in threads, fibers and ractors

An exception that is not rescued in a thread ends that thread only. The main thread is not told until it asks: Thread#join and Thread#value re-raise the exception in the caller. Meanwhile the interpreter prints a report to standard error when the thread dies (Thread.report_on_exception, true by default since Ruby 2.5, settable per thread), and Thread.abort_on_exception = true turns any thread’s failure into an exception in the main thread, which is convenient for scripts and wrong for servers.

Thread#raise injects an exception into another thread, the same mechanism that Timeout uses; since Ruby 4.0 it accepts cause: like Kernel#raise, and so does Fiber#raise. In a fiber, an unrescued exception propagates to whoever called resume. In a ractor, an exception ends the ractor and is delivered to the caller as Ractor::RemoteError with the original as its cause. The concurrency pages go deeper: Threads and the GVL, Fibers and the Fiber Scheduler and Ractors.

# frozen_string_literal: true

Thread.report_on_exception = false

worker = Thread.new { Integer("not a number") }
begin
  worker.join
rescue ArgumentError => e
  puts "join re-raised: #{e.message}"
end

begin
  Thread.new { raise IOError, "disk gone" }.value
rescue IOError => e
  puts "value re-raised: #{e.message}"
end
puts "status of a failed thread: #{worker.status.inspect}"

# Thread#raise injects an exception into another thread.
sleeper = Thread.new do
  sleep
rescue Interrupt => e
  "woken by #{e.class}: #{e.message}"
end
Thread.pass until sleeper.status == "sleep"
sleeper.raise(Interrupt, "wake up")
puts sleeper.value

# Since 4.0 the injected exception can carry its own cause.
t = Thread.new { sleep }
Thread.pass until t.status == "sleep"
t.raise(RuntimeError, "stop", cause: ArgumentError.new("because"))
begin
  t.join
rescue RuntimeError => e
  puts "#{e.message} <- #{e.cause.message}"
end
join re-raised: invalid value for Integer(): "not a number"
value re-raised: disk gone
status of a failed thread: nil
woken by Interrupt: wake up
stop <- because

Source: Thread

Ending the program

A Ruby program ends in one of four ways, each with a different exit status and different cleanup:

How Status What runs

Falling off the end of the script

0

at_exit handlers, in reverse registration order (the last registered runs first), then finalizers.

exit, exit(true), exit(false), exit(n)

0, 0, 1, n

Raises SystemExit; ensure clauses on the way up run; then at_exit handlers. SystemExit#status and #success? expose the status; rescue SystemExit stops it.

abort("message")

1

Prints the message to standard error, then behaves like exit(false).

An uncaught exception

1

ensure clauses while the exception unwinds, then the at_exit handlers, and only then the report (full_message with the cause chain) on standard error.

exit!

1 by default, or the given status

Ends the process immediately: no ensure, no at_exit, no flushing of Ruby’s buffers. For forked children that must not run the parent’s handlers.

An exit inside an at_exit handler changes the final status. END { } is a keyword form of at_exit that registers once (RuboCop’s Style/EndBlock asks for at_exit). Signals reach Ruby as exceptions in the main thread: Ctrl-C raises Interrupt (a SignalException with signo 2) and kill -TERM raises SignalException SIGTERM; handlers installed with Signal.trap replace that behaviour (see Processes, Signals and Shell).

The following program starts a child Ruby process for each ending and prints its status and output, so the cleanup behaviour is visible:

# frozen_string_literal: true

require "English"
require "rbconfig"

# Each child process shows one way a Ruby program ends.
CHILDREN = {
  "normal end" => "puts 'done'",
  "exit" => "exit",
  "exit(false)" => "exit(false)",
  "exit 3" => "exit 3",
  "abort" => "abort 'fatal: catalogue corrupt'",
  "uncaught raise" => "raise 'boom'",
  "rescued SystemExit" => <<~'RUBY'.tr("\n", ";"),
    begin
      exit 9
    rescue SystemExit => e
      puts "rescued status=#{e.status}"
    end
  RUBY
  "ensure and at_exit" => <<~RUBY.tr("\n", ";"),
    at_exit { puts 'at_exit 1' }
    at_exit { puts 'at_exit 2' }
    begin
      exit 4
    ensure
      puts 'ensure'
    end
  RUBY
  "exit!" => <<~RUBY.tr("\n", ";")
    at_exit { puts 'skipped' }
    begin
      exit! 5
    ensure
      puts 'skipped too'
    end
  RUBY
}.freeze

CHILDREN.each do |label, code|
  output = IO.popen([RbConfig.ruby, "-e", code, { err: %i[child out] }], &:read)
  puts "#{label}: status=#{$CHILD_STATUS.exitstatus}"
  output.each_line { |line| puts "    #{line}" }
end
normal end: status=0
    done
exit: status=0
exit(false): status=1
exit 3: status=3
abort: status=1
    fatal: catalogue corrupt
uncaught raise: status=1
    -e:1:in '<main>': boom (RuntimeError)
rescued SystemExit: status=0
    rescued status=9
ensure and at_exit: status=4
    ensure
    at_exit 2
    at_exit 1
exit!: status=5

Source: Kernel#exit

Interrupt handling in a script follows from the table. A program that has resources to release should rely on ensure, which runs for Interrupt and SystemExit too, and should not rescue Interrupt unless it has something to say:

# frozen_string_literal: true

# Ctrl-C arrives as an Interrupt raised in the main thread.
begin
  Process.kill("INT", Process.pid)
  sleep
rescue Interrupt => e
  puts "#{e.class} (a #{e.class.superclass}), message #{e.message.inspect}, signo #{e.signo}"
end

# Other signals arrive as a plain SignalException.
begin
  Process.kill("TERM", Process.pid)
  sleep
rescue SignalException => e
  puts "#{e.signm} (#{e.signo})"
end
Interrupt (a SignalException), message "", signo 2
SIGTERM (15)

Source: Interrupt

Source: SignalException

Handling errors at the edge of a program

Libraries raise; the program decides. In a command-line program, a service or a background job there is one place, the outermost method, that knows what a failure means for the user and the process: print a message, choose an exit status, log with the right severity, and perhaps retry the job. Everything below it just raises, and a rescue below it is justified only when it repairs or wraps.

The example is the shape most scripts want: a main that returns an exit status, a table that maps error classes to statuses (the values follow the BSD sysexits.h convention: 64 usage, 65 bad data, 70 internal error), a Logger rather than puts for diagnostics (severity, destination and format are configurable, and they go to standard error, not standard output, so the program’s output can be piped), and a single exit in the script body. The logger library is a bundled gem since Ruby 4.0, so a project that runs under Bundler needs gem "logger" in its Gemfile.

# frozen_string_literal: true

require "logger"
require "shelf"

LOGGER = Logger.new($stderr, formatter: proc { |severity, _time, _prog, message| "#{severity}: #{message}\n" })

module Shelf
  class UsageError < Error; end
end

# Exit statuses follow sysexits.h: 64 usage, 65 bad data, 70 internal error.
STATUS = { Shelf::UsageError => 64, ArgumentError => 65 }.freeze

def run(argv)
  raise Shelf::UsageError, "usage: shelf ISBN" if argv.empty?

  puts Shelf::Isbn.new(argv.first)
end

# One place decides how failures are reported and which status the process
# returns; everything below it just raises.
def main(argv)
  run(argv)
  0
rescue Shelf::Error, ArgumentError => e
  LOGGER.warn("#{e.class}: #{e.message}")
  STATUS.fetch(e.class, 64)
rescue StandardError => e
  LOGGER.error("#{e.class}: #{e.message}")
  70
end

[[], ["123"], ["978-0-306-40615-7"]].each do |argv|
  puts "status #{main(argv)}"
end
WARN: Shelf::UsageError: usage: shelf ISBN
status 64
WARN: ArgumentError: invalid ISBN: 123
status 65
9780306406157
status 0

Source: Kernel#exit

Command-line details such as OptionParser error classes and exit status conventions are in Command-Line Programs. For network code, remember that the errors worth rescuing from Net::HTTP are the transport errors (SocketError, Errno::ECONNREFUSED, Timeout::Error/Net::OpenTimeout/Net::ReadTimeout, OpenSSL::SSL::SSLError, EOFError), which are not subclasses of any Net::HTTP error class; a rescue Net::HTTPError handler written from an older tutorial misses them. That list lives with the HTTP client in Networking and HTTP.

Testing error paths

An error path that is not tested is a guess. Minitest’s assert_raises(Klass) { ... } passes when the block raises Klass or a subclass, fails when it raises nothing or something else, and returns the exception so that the message, the cause or the extra data can be asserted. RSpec’s equivalent is expect { ... }.to raise_error(Klass, message); see Testing with Minitest and RSpec and Test Doubles. Test the class and the data, and test the message with care: asserting an exact message ties the test to wording, and messages of core classes change between Ruby versions (see the version notes below).

# frozen_string_literal: true

require "minitest/autorun"
require "shelf"

class IsbnErrorsTest < Minitest::Test
  def test_rejects_short_isbn
    error = assert_raises(ArgumentError) { Shelf::Isbn.new("978") }
    assert_equal "invalid ISBN: 978", error.message
  end

  def test_subclass_is_accepted
    assert_raises(StandardError) { Shelf::Isbn.new("978") }
  end

  def test_valid_isbn_does_not_raise
    assert_equal "9780306406157", Shelf::Isbn.new("978-0-306-40615-7").digits
  end
end
3 runs, 4 assertions, 0 failures, 0 errors, 0 skips

Source: Minitest

(The output shows only the summary line of the run; Minitest also prints a random seed and the elapsed time.) minitest is a bundled gem in Ruby 4.0 (version 6.0.0): a project under Bundler lists it in its Gemfile.

Guidelines

The practices that the sections above justify, as a checklist:

  • Rescue the narrowest class that names the failure you can handle. rescue StandardError is acceptable only at a boundary, and rescue Exception almost never.

  • Keep the begin body to the statements that can raise the handled exception; put the success path in else.

  • Never leave a rescue clause empty. If an error is deliberately ignored, say why in a comment (RuboCop’s Lint/SuppressedException accepts a commented empty clause) or use a method that has a failure-free form.

  • Handle each failure once, at the layer that can act on it; wrap with cause when crossing layers; log at the edge.

  • Use ensure, or better a block form, for cleanup; never return from ensure.

  • Define a root error per library and subclass it; carry data in readers; do not parse messages.

  • Retry only transient, idempotent operations, with a limit and growing delays; never retry programming errors.

  • Prefer return values (exception: false, fetch with a default, find) for expected absence.

  • Put timeouts on the operation (IO#timeout=, HTTP client options) rather than around arbitrary code.

  • Test the error paths, and assert_raises the class and the data, not the wording.

RuboCop cops for exceptions

RuboCop ships a family of cops that encode these rules. All of them are enabled by default (the NewCops: enable setting used by the shelf configuration turns on the cops that are new in each release too).

Cop What it reports

Lint/RescueException

rescue Exception.

Style/RescueStandardError

A bare rescue (style explicit: write rescue StandardError). The implicit style does the opposite.

Lint/SuppressedException

An empty rescue clause (a comment inside is accepted by default).

Lint/SuppressedExceptionInNumberConversion

Integer(x) rescue nil and friends: use exception: false.

Style/RescueModifier

expression rescue fallback.

Lint/ShadowedException

A rescue clause listing a class after its superclass, which can never be reached.

Lint/DuplicateRescueException

The same class listed twice.

Lint/RescueType

A rescue with something that is not a class or module, such as a string.

Lint/EnsureReturn

return inside ensure.

Lint/EmptyEnsure and Lint/UselessRescue

An empty ensure, and a rescue that only re-raises.

Style/RedundantBegin

A begin block that a method body would make unnecessary.

Style/SignalException

fail (the default style only_raise requires raise).

Style/RaiseArgs

raise Klass.new("message") when raise Klass, "message" is enough.

Style/RedundantException

raise RuntimeError, "message" where raise "message" is equivalent.

Lint/RaiseException and Lint/InheritException

raise Exception and class MyError < Exception.

Layout/RescueEnsureAlignment

A rescue or ensure not aligned with its begin or def.

See Style, Linting and Static Analysis for configuring and running RuboCop.

Pitfalls

  • rescue Exception swallows exit and Ctrl-C. A script that cannot be interrupted usually has one.

  • raise Klass, key: value does not call the constructor with a keyword. The keywords become the message. Raise an object: raise Klass.new(...).

  • Exceptions in else, ensure and rescue are not seen by the sibling rescue clauses. Nest a begin if needed.

  • return in ensure hides the exception in flight.

  • A rescue entry that is not a class (a string, a method that returned nil) raises TypeError when an exception arrives, and hides it.

  • NotImplementedError is not rescued by a bare rescue.

  • retry without a limit is an infinite loop for a permanent failure.

  • Timeout.timeout can interrupt anything, including cleanup. Use the operation’s own timeout.

  • Thread.abort_on_exception = true in a library changes the behaviour of the whole process. Use report_on_exception and join.

  • $! outside a rescue clause is nil, and inside it can change when another exception is handled in between. Use the variable of rescue => e.

  • Error messages that include secrets (passwords, tokens, full URLs with credentials) end up in logs and error trackers. Include identifiers, not the data.

  • Matching on message text (e.message.include?("not found")) breaks when wording changes. Use classes and readers.

  • Rescuing NoMethodError to detect nil. Use &., a guard clause or a default; a rescued NoMethodError also hides typos.

  • A rescue in a brace block is a syntax error; use do/end.

  • Rescuing from a hot loop. Raising captures a backtrace; return a value in code that runs often.

Comparison with other languages

Language Model Difference from Ruby

Java

try/catch/finally, checked and unchecked exceptions, try-with-resources.

Ruby has no checked exceptions and no throws; ensure replaces finally, and a block form replaces try-with-resources.

Python

try/except/else/finally, raise ... from.

The closest model. Ruby’s cause is set implicitly, and a bare rescue is StandardError, like a bare except Exception.

Kotlin and C#

Unchecked exceptions, try is an expression (Kotlin).

Ruby’s begin is an expression too; there is no exception filter (when), but rescue accepts any object with ===.

Go and Rust

Errors are return values (error, Result).

Ruby returns values for expected absence (nil, exception: false) and raises for faults.

Swift

throws/try/catch, typed by protocol.

Swift makes every throwing call visible with try; Ruby does not.

Where the books are outdated or wrong

Older books and tutorials on Ruby error handling are still useful for the ideas, but several details have changed or were never right. Wherever a printed example differs from the output on this page, this table says why.

Area Older material shows Current approach (Ruby 4.0)

Error message format

undefined method `foo' for nil:NilClass, with a backtick and a class name.

undefined method 'foo' for nil (Ruby 3.3 receiver format, 3.4 apostrophe) and, for other objects, for an instance of Shelf::Isbn.

Backtrace labels

in `parse'.

in 'Object#parse', with the class (Ruby 3.4).

Hash in a message or inspect

{:name=>"Ico"}.

{name: "Ico"} and {"name" => "Ico"} (Ruby 3.4).

Abstract methods

raise NotImplementedError in the base class.

NotImplementedError is a ScriptError that a bare rescue does not catch. Raise NoMethodError or a project error.

Catch-all handling

rescue Exception => e as the general handler.

rescue StandardError, or a bare rescue. rescue Exception breaks exit, Ctrl-C and timeouts.

Wrapping errors

A plain Exception instance or subclass used as the wrapper, and a rescue of a class that is not defined in that scope.

A StandardError subclass with cause; the root error of the library.

Network errors

rescue Net::HTTPError around an HTTP call.

The transport errors (SocketError, Errno::*, Timeout::Error, OpenSSL::SSL::SSLError) are not Net::HTTP errors.

Logging

Logger described as part of the standard library.

logger is a bundled gem since Ruby 4.0: under Bundler it needs a Gemfile entry.

Printing order

Output headed "Traceback (most recent call last)", outermost frame first (an experiment of Ruby 2.5 to 2.7 on terminals).

Innermost first again since Ruby 3.0; full_message(order: :bottom) gives the reversed layout.

Cause

A custom attribute (original) to keep the wrapped error.

Exception#cause is set automatically (Ruby 2.1) and cause: overrides it.

Pattern-matching errors

Not covered.

NoMatchingPatternError and NoMatchingPatternKeyError for case/in and => with no match.

Version notes

Version Change

2.1

Exception#cause: the exception being handled is recorded automatically.

2.5

rescue, else and ensure allowed directly in do/end blocks. Exception#full_message. FrozenError. KeyError#key and #receiver. Thread.report_on_exception defaults to true. An experimental reverse-order error printer on terminals (undone in 3.0).

3.0

The error printer shows the innermost frame first. --backtrace-limit. Warning.warn accepts category:.

3.1

error_highlight ships with Ruby. Class#subclasses. Timeout.timeout is non-blocking under a fiber scheduler. Thread::Backtrace.limit.

3.2

Exception#detailed_message; the error printer calls it. IO#timeout= and IO::TimeoutError. Regexp.timeout=. Thread::Queue#pop(timeout:). Control characters are no longer escaped in error messages.

3.3

NoMethodError messages say for an instance of ClassName instead of calling inspect. TracePoint :rescue event. A :performance warning category.

3.4

Error messages and backtraces use an apostrophe instead of a backtick, and method labels include the class. Extra rescue and ensure frames are gone from backtraces. Exception#set_backtrace, Kernel#raise, Thread#raise and Fiber#raise accept arrays of Thread::Backtrace::Location. Warning.categories. Negative Timeout.timeout values are rejected. Hash#inspect format changed.

4.0

error_highlight shows caller and callee snippets for ArgumentError about arity. Thread#raise(cause:) and Fiber#raise(cause:). Backtraces no longer show internal frames of core methods written in Ruby; the backtrace of an arity ArgumentError names the class. TCPSocket.new and Socket.tcp raise IO::TimeoutError consistently and accept open_timeout:. logger and minitest are bundled gems.