Error Handling and Exceptions
begin/rescue/ensure, custom exception classes, retry, and why rescue Exception is a trap.
begin/rescue/ensure/else
Ruby handles errors with begin/rescue, conceptually similar to try/catch in most other languages, plus two extra clauses that are genuinely useful and easy to miss if you've only used try/catch elsewhere:
def divide(a, b)
begin
result = a / b
rescue ZeroDivisionError => e
puts "Error: #{e.message}"
result = nil
else
puts "Division succeeded" # runs only if NO exception was raised
ensure
puts "Division attempt finished" # always runs, exception or not
end
result
end
divide(10, 2)
# Division succeeded
# Division attempt finished
divide(10, 0)
# Error: divided by 0
# Division attempt finished
| Clause | Runs when | Typical use |
|---|---|---|
rescue |
An exception matching the given class (or its subclasses) was raised | Handle or log the error, provide a fallback value |
else |
The begin block completed with no exception raised |
Code that should only run on the success path |
ensure |
Always — whether an exception was raised, rescued, or not | Cleanup: closing a file, releasing a connection, logging completion |
A method body doesn't need an explicit begin/end wrapper at all — rescue/ensure can attach directly to the method definition itself, which is the more common style in real Ruby code:
def divide(a, b)
a / b
rescue ZeroDivisionError => e
puts "Error: #{e.message}"
nil
ensure
puts "Division attempt finished"
end
Rescuing specific exception classes, in order
rescue can appear multiple times, matching different exception classes, and Ruby tries them top-to-bottom, using the first one that matches:
def parse_and_divide(a, b_str)
b = Integer(b_str) # raises ArgumentError if b_str isn't a valid integer string
a / b
rescue ZeroDivisionError
puts "Cannot divide by zero"
rescue ArgumentError => e
puts "Invalid number: #{e.message}"
rescue StandardError => e
puts "Something else went wrong: #{e.message}"
end
rescue with no explicit class (as seen in the first example's bare rescue => e) implicitly means rescue StandardError => e — it does not catch every possible exception. That's a deliberate design choice: Ruby's exception hierarchy has more severe exceptions like NoMemoryError, SystemExit, and Interrupt (raised by Ctrl+C) directly under Exception rather than StandardError, precisely so an ordinary rescue doesn't accidentally swallow a user's attempt to kill the program.
Custom exception classes
Real applications define their own exception classes for domain-specific error conditions, by inheriting from StandardError (never from the broader Exception directly):
class InsufficientFundsError < StandardError
def initialize(msg = "Not enough funds for this withdrawal")
super
end
end
class Account
attr_reader :balance
def initialize(balance)
@balance = balance
end
def withdraw(amount)
raise InsufficientFundsError if amount > @balance
@balance -= amount
end
end
account = Account.new(100)
begin
account.withdraw(150)
rescue InsufficientFundsError => e
puts e.message # Not enough funds for this withdrawal
end
A custom exception class can carry its own data beyond a message, which is often more useful than a plain string in real error-handling code:
class InsufficientFundsError < StandardError
attr_reader :shortfall
def initialize(shortfall)
@shortfall = shortfall
super("Short by #{shortfall}")
end
end
begin
raise InsufficientFundsError.new(50)
rescue InsufficientFundsError => e
puts e.message # Short by 50
puts e.shortfall # 50 — the caller can react to the actual number, not just parse a string
end
retry
retry jumps execution back to the very beginning of the begin block, which is useful for transient failures — a flaky network call, a momentary lock conflict — as long as it's bounded:
attempts = 0
begin
attempts += 1
raise "Temporary failure" if attempts < 3
puts "Succeeded on attempt #{attempts}"
rescue => e
if attempts < 3
retry
else
puts "Giving up after #{attempts} attempts: #{e.message}"
end
end
# Succeeded on attempt 3
Without the attempts < 3 guard, this would retry forever the moment the underlying failure is permanent rather than transient — retry has no built-in limit, so bounding it explicitly is the caller's responsibility.
Common mistakes
- Writing
rescue Exception => einstead ofrescue StandardError => e(or barerescue) —Exceptionalso catches things likeSystemExitandInterrupt, meaning a broadrescue Exceptioncan make a program impossible to stop with Ctrl+C or swallow a fatal out-of-memory condition as if it were an ordinary recoverable error. - Using
raise/rescuefor ordinary control flow (e.g., raising to break out of a deeply nested loop) instead of a normal conditional orthrow/catch— exceptions in Ruby carry real overhead and are meant for genuinely exceptional conditions, not routine branching. - Writing an unbounded
retryloop with no attempt counter or backoff, which spins forever against a failure that will never actually resolve itself. - Forgetting that
ensureruns even when the code insidebegin/rescueexecutes areturn— this is usually what you want (cleanup always happens), but it's easy to be surprised the first time by anensureblock that overwrites a value the method was about to return.
Interview questions
Q: What's the difference between rescue, else, and ensure in a Ruby begin/rescue block?
rescue runs only when a matching exception is raised. else runs only when the begin block completes with no exception raised at all. ensure always runs, regardless of whether an exception occurred or was rescued — it's the right place for cleanup that must happen either way, like closing a file or a connection.
Q: Why is rescue Exception => e considered bad practice compared to a bare rescue or rescue StandardError => e?
Ruby's exception hierarchy puts severe, typically-non-recoverable conditions — SystemExit, Interrupt (from Ctrl+C), NoMemoryError — directly under Exception rather than under StandardError. A bare rescue (or an explicit rescue StandardError) only catches ordinary, recoverable application errors; rescue Exception also catches those severe cases, which can make a program refuse to exit on Ctrl+C or silently proceed after a condition it really shouldn't recover from.
Q: What does retry do inside a rescue block, and what risk does it carry?
It jumps execution back to the start of the enclosing begin block, re-running everything from the top — commonly used to re-attempt an operation that failed for a transient reason. The risk is that retry has no built-in attempt limit, so without an explicit counter (or similar guard) bounding how many times it can fire, a permanently failing operation retries forever instead of eventually giving up and surfacing the error.