Exceptions in Ruby
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
StandardErrororExceptionbroadly — 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 callsuperso#messageworks 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
iforfetchwith a default. - Put
ensureblocks around anything that must be cleaned up — open file handles, database connections, locks — sinceensureruns even if the method returns early or an exception propagates past it. - When you re-raise inside a rescue block, use a bare
raiseto preserve the original exception and its backtrace, rather than raising a brand-new one that hides where the problem actually started. - Guard every
retrywith a counter or condition; an unconditionalretryon 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 rescuesZeroDivisionErrorwhen the array is empty and returns0instead of crashing. - Define a custom exception
NegativeAgeError < StandardErrorand a methodset_age(age)that raises it with a message including the invalid value wheneverageis 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
retrywith a maximum of 3 attempts, printing which attempt finally succeeded.
Summary
- An exception is an object (an instance of
Exceptionor a subclass) that represents an error condition and unwinds the call stack until something rescues it. begin/rescue/else/ensure/endis the full syntax; methods get an implicitbegin/end, sorescueandensurecan attach directly to adef.- A bare
rescueorrescue => eonly catchesStandardErrorand its descendants — catchingExceptiondirectly is almost always wrong. - Custom exceptions must inherit from
StandardError(or anotherExceptionsubclass) to be raisable at all. ensurealways runs, exception or not, making it the right place for cleanup code.retryrestarts thebeginblock from the top; always pair it with a guard condition to avoid infinite loops.- Exception objects expose
#message,#class,#backtrace, and#causefor chained errors.
