Exceptions in Ruby

Published by Programming Line in Ruby. Last updated .

An exception in Ruby is an object that represents something exceptional going wrong while your program runs — a missing file, a division by zero, a network call that times out. Instead of returning a special error value that every caller must remember to check, Ruby raises an exception object, which travels up the call stack looking for code that wants to rescue it. If nothing rescues it, the program halts and prints a stack trace. This mechanism lets you write the “happy path” of your code cleanly, and handle failures in one dedicated place instead of scattering error checks everywhere.

Overview: How Exceptions Work

Because Ruby treats (almost) everything as an object, an exception is just an instance of a class — specifically, a subclass of Exception. When you call raise, Ruby creates (or reuses) an exception object and starts unwinding the call stack: the current method stops executing immediately, and control jumps outward to its caller, then that caller’s caller, and so on, until it finds a rescue clause whose exception class matches (or is an ancestor of) the raised object’s class. If no matching rescue exists anywhere up the chain, the exception reaches the top level, Ruby prints the exception’s class, message, and backtrace to standard error, and the process exits with a non-zero status.

Ruby’s built-in exceptions form a class hierarchy rooted at Exception. Most exceptions you raise and rescue — and every exception the standard library raises for ordinary problems — are instances of StandardError or one of its many subclasses (ArgumentError, TypeError, ZeroDivisionError, NoMethodError, IndexError, KeyError, RuntimeError, and more). A handful of exceptions sit outside StandardError, directly under Exception: things like NoMemoryError, SystemExit (raised when you call exit), and SignalException/Interrupt (raised on Ctrl-C). These represent conditions your code almost never wants to intercept, so Ruby makes a deliberate design choice: a bare rescue or rescue => e (with no class named) only catches StandardError and its descendants. You have to opt in explicitly with rescue Exception => e to catch everything, and doing so is almost always a mistake (more on that below).

Every exception object carries useful information: #message returns a human-readable description, #class tells you exactly what went wrong, and #backtrace returns an array of strings showing the call stack at the moment it was raised. When you rescue one exception and raise a new one inside that rescue block, Ruby automatically records the original as the new exception’s #cause, so you can trace a chain of failures back to their root.

Syntax

The full form uses begin/rescue/else/ensure/end:

begin
  # code that might raise
rescue SomeError => e
  # handle SomeError
rescue AnotherError => e
  # handle AnotherError
else
  # runs only if no exception was raised
ensure
  # always runs, exception or not
end
Part Purpose
begin … end Marks the block of code that might raise an exception.
rescue ClassName => e Catches exceptions that are instances of ClassName or a subclass, binding the object to local variable e. You can list several rescue clauses; the first matching one runs. Order matters — put more specific classes before more general ones.
rescue A, B => e A single clause can match several classes at once, separated by commas.
else Runs only if the begin body completed with no exception raised. Rarely used, but useful to separate “only on success” logic from the risky code itself.
ensure Always runs — whether an exception was raised, rescued, unrescued, or the block returned normally. Used for cleanup like closing files or releasing locks.

Since a method definition already has an implicit begin/end around its body, you can attach rescue and ensure directly to a def without writing begin at all:

def risky_operation
  1 / 0
rescue ZeroDivisionError => e
  puts "Handled: #{e.message}"
end

risky_operation
Handled: divided by 0

Examples

Example 1: Rescuing a specific error

def safe_divide(a, b)
  a / b
rescue ZeroDivisionError => e
  puts "Error: #{e.message}"
  nil
end

puts safe_divide(10, 2)
puts safe_divide(10, 0)
5
Error: divided by 0

The first call divides cleanly, so a / b is the last expression evaluated and becomes the method’s return value: 5. The second call divides by zero, which raises a ZeroDivisionError before the method can return anything. Control jumps straight to the matching rescue clause, which prints the message and evaluates to nil — and since a rescue clause’s last expression becomes the method’s return value, safe_divide returns nil. The outer puts nil prints an empty line, which is why there’s a trailing blank line in the output.

Example 2: Multiple rescue clauses with else and ensure

def parse_number(str)
  begin
    Integer(str)
  rescue ArgumentError => e
    puts "Not a valid integer: #{str.inspect}"
    nil
  rescue TypeError => e
    puts "Wrong type: #{e.message}"
    nil
  else
    puts "Parsed successfully"
  ensure
    puts "Finished attempting to parse #{str.inspect}"
  end
end

parse_number("42")
parse_number("abc")
Parsed successfully
Finished attempting to parse "42"
Not a valid integer: "abc"
Finished attempting to parse "abc"

Integer("42") succeeds, so no exception is raised, the else clause runs (printing “Parsed successfully”), and then ensure runs regardless, printing the “Finished” line. Integer("abc") cannot be converted, so Ruby raises an ArgumentError; the matching rescue clause runs instead of else, and ensure still runs afterward. Notice that ensure fires in both cases — it’s the one block of code you can count on no matter what happened inside begin.

Example 3: A custom exception class

class InsufficientFundsError < StandardError
  def initialize(msg = "not enough funds in account")
    super
  end
end

class Account
  attr_reader :balance

  def initialize(balance)
    @balance = balance
  end

  def withdraw(amount)
    raise InsufficientFundsError, "cannot withdraw #{amount}, balance is #{@balance}" if amount > @balance

    @balance -= amount
  end
end

account = Account.new(100)

begin
  account.withdraw(150)
rescue InsufficientFundsError => e
  puts "Transaction failed: #{e.message}"
end

puts "Final balance: #{account.balance}"
Transaction failed: cannot withdraw 150, balance is 100
Final balance: 100

Real programs benefit from exception classes that describe your domain, not just Ruby’s built-in ones. InsufficientFundsError inherits from StandardError (this is essential — more on that in Common Mistakes) and defines a sensible default message. Calling super with no parentheses and no arguments forwards whatever arguments initialize received straight to StandardError#initialize, which stores the message. When withdraw is called with an amount bigger than the balance, it raises the custom error with a specific message; the balance is left untouched because the raise happens before the line that would have decremented it.

How It Works Step by Step: retry

retry jumps back to the very beginning of the begin block, re-running it from scratch. It’s the mechanism behind “try again a few times before giving up” logic, such as retrying a flaky network request.

attempts = 0

begin
  attempts += 1
  raise "temporary failure" if attempts < 3
  puts "Succeeded after #{attempts} attempts"
rescue RuntimeError => e
  retry if attempts < 3
  puts "Giving up: #{e.message}"
end
Succeeded after 3 attempts

Walking through it: attempts starts at 0. First pass increments it to 1; since 1 is less than 3, raise fires a plain RuntimeError (the default class when you raise a string). The rescue clause checks attempts < 3, which is true, so retry sends execution back to the top of begin — not back to the top of the method, just the block. The same thing happens on attempt 2. On attempt 3, the condition attempts < 3 is now false, so no exception is raised and the success message prints. If it had failed a third time, the rescue's retry if attempts < 3 would be false, so execution would fall through to puts "Giving up: ..." instead — that guard is what prevents an infinite retry loop.

Common Mistakes

Mistake 1: Rescuing Exception instead of StandardError

begin
  raise "boom"
rescue Exception => e
  puts "Caught: #{e.message}"
end
Caught: boom

This runs fine and looks harmless, but it's a trap: Exception is the root of the entire hierarchy, so this clause will also swallow SystemExit (raised by exit), Interrupt (raised by Ctrl-C), and NoMemoryError — conditions your rescue almost certainly isn't designed to handle, and definitely shouldn't silently suppress. Nearly every “catch anything” rescue should target StandardError, which is exactly what a bare rescue => e already does:

begin
  raise "boom"
rescue StandardError => e
  puts "Caught: #{e.message}"
end
Caught: boom

Mistake 2: A custom exception that doesn't inherit from StandardError

class MyCustomError
  def initialize(msg = "custom error")
    @msg = msg
  end
end

begin
  raise MyCustomError, "something broke"
rescue MyCustomError => e
  puts e.message
end

It's tempting to write a plain class to represent an error and forget the inheritance. But raise requires an Exception (or a subclass), and a bare class MyCustomError inherits from Object, not Exception. This code raises TypeError: exception class/object expected before it ever reaches the rescue clause — the rescue can't save you from an object that was never a valid exception in the first place. The fix is to inherit from StandardError and forward the message with super:

class MyCustomError < StandardError
  def initialize(msg = "custom error")
    super(msg)
  end
end

begin
  raise MyCustomError, "something broke"
rescue MyCustomError => e
  puts e.message
end
something broke

Best Practices

  • Rescue the most specific exception class you can, not StandardError or Exception broadly — a rescue that's too wide hides bugs you'd otherwise notice immediately.
  • Always inherit custom exceptions from StandardError (or a more specific existing exception), and call super so #message works as expected.
  • Never use exceptions for ordinary control flow (like checking whether a key exists) — raising and rescuing is comparatively slow and makes the logic harder to follow than an if or fetch with a default.
  • Put ensure blocks around anything that must be cleaned up — open file handles, database connections, locks — since ensure runs even if the method returns early or an exception propagates past it.
  • When you re-raise inside a rescue block, use a bare raise to preserve the original exception and its backtrace, rather than raising a brand-new one that hides where the problem actually started.
  • Guard every retry with a counter or condition; an unconditional retry on a persistent failure is an infinite loop.
  • Give custom exceptions a descriptive default message and, where useful, extra attributes (like the offending amount or record id) so callers can react programmatically instead of parsing the message string.

Practice Exercises

  • Write a method average(numbers) that returns the average of an array of numbers, but rescues ZeroDivisionError when the array is empty and returns 0 instead of crashing.
  • Define a custom exception NegativeAgeError < StandardError and a method set_age(age) that raises it with a message including the invalid value whenever age is negative. Rescue it in a small script and print a friendly message.
  • Write a method that simulates an unreliable operation (raise on the first two calls, succeed on the third, similar to the retry example) and use retry with a maximum of 3 attempts, printing which attempt finally succeeded.

Summary

  • An exception is an object (an instance of Exception or a subclass) that represents an error condition and unwinds the call stack until something rescues it.
  • begin/rescue/else/ensure/end is the full syntax; methods get an implicit begin/end, so rescue and ensure can attach directly to a def.
  • A bare rescue or rescue => e only catches StandardError and its descendants — catching Exception directly is almost always wrong.
  • Custom exceptions must inherit from StandardError (or another Exception subclass) to be raisable at all.
  • ensure always runs, exception or not, making it the right place for cleanup code.
  • retry restarts the begin block from the top; always pair it with a guard condition to avoid infinite loops.
  • Exception objects expose #message, #class, #backtrace, and #cause for chained errors.