When I first started writing Ruby, I treated error handling like an afterthought. I’d write my happy-path code, ship it, and only think about exceptions when something blew up in production at 2 AM. Over the years I’ve completely flipped that mindset. Good error handling isn’t defensive paranoia — it’s part of the design of the program. In this article, I want to walk you through everything I’ve learned about Ruby’s exception system, the often-misunderstood catch and throw keywords, and how Ruby’s control flow really works under the hood.
This is a long one, so grab a coffee. I’ll go from the absolute basics to the internals of how Ruby represents exceptions as objects, and I’ll show you the patterns I actually use in real codebases.
Why Error Handling Deserves Its Own Mental Model
In a lot of languages, errors are treated as return codes or flags you have to check manually. Ruby, like Python and Java, treats errors as objects that get raised and rescued. This is a huge shift in mindset if you’re coming from C or older-style JavaScript. Instead of checking if result == nil after every call, you write code assuming things will work, and you handle the exceptional cases separately, where they belong.
I like this approach because it keeps my “main” logic readable. I don’t have to litter every third line with error checks. But it does mean I need to understand exactly how Ruby’s exception machinery works, because sloppy rescue blocks can hide real bugs.
The Exception Class Hierarchy
Everything in Ruby’s error system starts with the Exception class. Here’s the hierarchy I keep in my head:
Exception
NoMemoryError
ScriptError
LoadError
NotImplementedError
SyntaxError
SecurityError
SignalException
Interrupt
StandardError
ArgumentError
EncodingError
FiberError
IOError
EOFError
IndexError
KeyError
StopIteration
LocalJumpError
NameError
NoMethodError
RangeError
FloatDomainError
RegexpError
RuntimeError (default for raise)
ThreadError
TypeError
ZeroDivisionError
SystemExit
SystemStackError
The most important thing to understand here is that rescue without an explicit class only catches StandardError and its subclasses. It does not catch Exception itself. This is intentional. Things like SystemExit (raised when you call exit) or NoMemoryError are not meant to be casually swallowed by your rescue blocks. I’ve seen junior developers write rescue Exception => e thinking it’s “more thorough,” and it actually breaks things like Ctrl+C interrupts and clean process exits. I never do this, and I’d recommend you avoid it too unless you have a very specific reason (like a top-level crash logger that re-raises afterward).
Basic Syntax: begin, rescue, else, ensure
Let me start with the core building block:
begin
result = 10 / 0
rescue ZeroDivisionError => e
puts "Caught an error: #{e.message}"
ensure
puts "This always runs, error or not"
end
Output:
Caught an error: divided by 0
This always runs, error or not
A few things I want to point out here:
rescue ZeroDivisionError => ecatches the specific exception class and binds it to the local variablee.ensureruns no matter what — whether an exception was raised, rescued, or not raised at all. I useensureconstantly for cleanup: closing files, releasing database connections, unlocking mutexes.- There’s also an
elseclause that runs only if no exception was raised:
begin
result = 10 / 2
rescue ZeroDivisionError => e
puts "Error: #{e.message}"
else
puts "Success! Result is #{result}"
ensure
puts "Cleanup happens here"
end
Output:
Success! Result is 5
Cleanup happens here
I don’t use else as often as ensure, but it’s genuinely useful when you want to separate “code that might fail” from “code that should only run after success” — it keeps the rescue block focused purely on error recovery.
Method-Level Rescue (No begin Needed)
Something I really appreciate about Ruby is that you don’t need an explicit begin block inside a method — the method definition itself acts as an implicit begin/end:
def divide(a, b)
a / b
rescue ZeroDivisionError => e
puts "Can't divide by zero: #{e.message}"
nil
end
divide(10, 0)
Output:
Can't divide by zero: divided by 0
I use this style all the time because it reduces indentation and keeps methods shorter. It’s genuinely idiomatic Ruby.
Rescuing Multiple Exception Types
You can rescue multiple classes in one clause, or stack multiple rescue clauses for different handling logic:
def parse_input(value)
Integer(value) / 0
rescue ArgumentError, TypeError => e
puts "Bad input: #{e.message}"
rescue ZeroDivisionError => e
puts "Math error: #{e.message}"
rescue => e
puts "Something else went wrong: #{e.class} - #{e.message}"
end
parse_input("abc")
parse_input("10")
Output:
Bad input: invalid value for Integer(): "abc"
Math error: divided by 0
Notice the order matters — Ruby checks rescue clauses top to bottom and uses the first one that matches, similar to a case statement. I always put the most specific exception classes first and the generic rescue => e catch-all last.
Raising Exceptions
I raise exceptions constantly to enforce invariants in my code. The raise keyword has a few forms:
raise "Something went wrong" # RuntimeError with message
raise ArgumentError, "age must be positive" # specific class + message
raise ArgumentError.new("age must be positive") # equivalent, using .new
Here’s a real pattern I use for validating method arguments:
def set_age(age)
raise ArgumentError, "age must be a positive integer" unless age.is_a?(Integer) && age.positive?
@age = age
end
set_age(-5)
Output:
ArgumentError (age must be a positive integer)
Building Custom Exception Classes
This is where error handling starts to feel like real application design. Instead of raising generic RuntimeErrors everywhere, I define my own exception hierarchy that mirrors my domain.
class ApplicationError < StandardError; end
class InsufficientFundsError < ApplicationError
attr_reader :balance, :requested_amount
def initialize(balance:, requested_amount:)
@balance = balance
@requested_amount = requested_amount
super("Insufficient funds: tried to withdraw #{requested_amount}, but balance is #{balance}")
end
end
class Account
attr_reader :balance
def initialize(balance)
@balance = balance
end
def withdraw(amount)
if amount > balance
raise InsufficientFundsError.new(balance: balance, requested_amount: amount)
end
@balance -= amount
end
end
account = Account.new(100)
begin
account.withdraw(500)
rescue InsufficientFundsError => e
puts e.message
puts "Shortfall: #{e.requested_amount - e.balance}"
end
Output:
Insufficient funds: tried to withdraw 500, but balance is 100
Shortfall: 400
I always create a base error class (ApplicationError here) for my application or gem, then subclass it for specific error conditions. This lets consumers of my code choose how granular they want to be — they can rescue the base class to catch anything from my app, or a specific subclass for fine-grained handling.
The retry Keyword
retry is one of those features I didn’t appreciate until I started writing code that talks to flaky external services (APIs, databases with connection hiccups, etc.). It jumps back to the beginning of the begin block:
attempts = 0
begin
attempts += 1
puts "Attempt #{attempts}"
raise "Connection failed" if attempts < 3
puts "Connected successfully!"
rescue => e
if attempts < 3
puts "Retrying after: #{e.message}"
retry
else
puts "Giving up after #{attempts} attempts"
end
end
Output:
Attempt 1
Retrying after: Connection failed
Attempt 2
Retrying after: Connection failed
Attempt 3
Connected successfully!
I always cap my retries with a counter like this. An unconditional retry is a great way to write an infinite loop by accident, and I’ve done that at least once early in my career.
catch and throw: Ruby’s Other Control Flow Tool
This is the part people confuse with exception handling, and I want to be very clear: catch/throw is not for error handling. It’s a general-purpose, non-local jump mechanism — a way to break out of deeply nested loops or blocks without raising an actual exception object.
result = catch(:found) do
(1..100).each do |i|
(1..100).each do |j|
if i * j == 50
throw :found, [i, j]
end
end
end
nil
end
puts result.inspect
Output:
[1, 50]
Here’s how I think about the difference:
raise/rescueis for signaling that something went wrong — an exceptional, often error-like condition.throw/catchis for jumping out of a normal, non-error control flow — like escaping nested loops early once you’ve found what you wanted.
throw and catch are matched by a symbol tag (:found in my example), not by class, and there’s no concept of a hierarchy like there is with exceptions. If you throw a tag that has no matching catch anywhere up the call stack, Ruby raises an UncaughtThrowError, which is a real exception:
throw :nonexistent_tag
Output:
UncaughtThrowError (uncaught throw :nonexistent_tag)
I use catch/throw rarely — mostly for early-exit scenarios in nested iterations where break alone isn’t enough because I’m several loop levels deep. Honestly, in most real code I write, I refactor the nested loops into a method and use return instead, which is usually cleaner. But it’s good to know catch/throw exists for the cases where extraction into a method isn’t convenient.
Internal Working: How Exceptions Actually Propagate
Under the hood, when you call raise, Ruby creates (or reuses) an exception object and unwinds the call stack, looking frame by frame for a matching rescue clause. This unwinding is not free — it’s more expensive than a normal method return because the Ruby VM (YARV) has to walk back through the stack frames, check for ensure blocks that need to run, and match exception classes against active rescue clauses.
This is why exceptions in Ruby, like in most languages, should be reserved for truly exceptional situations — not for routine control flow. I’ve seen code that uses raise/rescue to break out of loops or handle “user not found” as if it were catastrophic. It works, but it’s slower than it needs to be and it makes the code harder to reason about, because now NoUserFoundError looks the same, structurally, as DatabaseConnectionLost.
A concrete illustration of the performance cost:
require 'benchmark'
Benchmark.bm do |x|
x.report("with exceptions:") do
100_000.times do
begin
raise "test"
rescue
nil
end
end
end
x.report("with return values:") do
100_000.times do
result = begin
:error
end
end
end
end
On my machine, the exception-based loop runs several times slower than the plain conditional version. That gap grows if the exceptions carry a full backtrace (which they do by default). If you’re in a hot path and need to signal “not found” or similar frequent, expected conditions, returning nil, a sentinel value, or a Result-style object is usually the better idea. Save real exceptions for genuinely unexpected failures.
Exception Objects: message, backtrace, and cause
Every exception instance carries useful introspection data:
begin
begin
raise ArgumentError, "bad input"
rescue ArgumentError => inner
raise TypeError, "wrapping error"
end
rescue TypeError => outer
puts outer.message
puts outer.cause.class
puts outer.cause.message
puts outer.backtrace.first
end
Output:
wrapping error
ArgumentError
bad input
(the file and line number where TypeError was raised)
The cause chain is something I lean on heavily when wrapping low-level errors into higher-level, domain-specific ones. It preserves the original exception so I don’t lose debugging context, while still presenting a clean, meaningful error type to the caller.
Common Mistakes I See (and Have Made Myself)
Rescuing too broadly. rescue => e inside a tight loop, silently swallowing everything, is a classic way to hide real bugs. I always log or re-raise unless I have a genuinely good reason to suppress an error.
Using exceptions for expected conditions. If “user not found” happens on every third request, that’s not exceptional — model it with a return value.
Forgetting that ensure can swallow return values. If your ensure block has an explicit return, it silently overrides whatever the begin block returned. I avoid return inside ensure for this exact reason.
Not re-raising after logging. If you catch an error just to log it, but the caller still needs to know something failed, re-raise it (raise with no arguments inside a rescue block re-raises the current exception).
def risky_operation
do_something
rescue => e
logger.error("Failed: #{e.message}")
raise
end
Best Practices I Actually Follow
- Rescue the most specific exception class you can, not the broadest one.
- Build a small hierarchy of custom exceptions per application/gem, rooted in a single base class.
- Use
ensurefor cleanup, notrescue. - Never use
rescue Exceptionunless you’re writing top-level process supervision code that re-raises afterward. - Reserve exceptions for actually exceptional situations; use return values for expected, frequent conditions.
- Always preserve the
causechain when wrapping exceptions. - Use
catch/throwsparingly, and only for non-error control flow like early exits from deep nesting.
Summary
Ruby’s exception system is more thoughtfully designed than it first appears. The Exception class hierarchy gives you fine control over what gets rescued and what doesn’t, begin/rescue/else/ensure gives you a clean structure for handling and cleaning up after failures, and custom exception classes let you model your application’s error conditions as first-class citizens instead of stringly-typed messages. catch/throw, meanwhile, is a completely separate tool for non-local jumps that has nothing to do with error handling, despite superficially looking similar.
The biggest shift for me, going from “just make errors go away” to actually good error handling, was learning to treat exceptions as part of my domain model rather than a nuisance to suppress. Once you start designing your exception hierarchy the same way you design your classes, your error handling code stops being an afterthought and starts being one of the more elegant parts of your codebase.
References
- Ruby Official Documentation — Exception Handling
- Ruby Core API — Exception class
- Ruby Core API — Kernel#catch and Kernel#throw
- Ruby Core API — StandardError
- RubyGems Guides — Publishing your gem and versioning practices