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:

Ruby
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:

Ruby
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:

Ruby
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):

Ruby
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:

Ruby
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:

Ruby
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 => e instead of rescue StandardError => e (or bare rescue) — Exception also catches things like SystemExit and Interrupt, meaning a broad rescue Exception can 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/rescue for ordinary control flow (e.g., raising to break out of a deeply nested loop) instead of a normal conditional or throw/catch — exceptions in Ruby carry real overhead and are meant for genuinely exceptional conditions, not routine branching.
  • Writing an unbounded retry loop with no attempt counter or backoff, which spins forever against a failure that will never actually resolve itself.
  • Forgetting that ensure runs even when the code inside begin/rescue executes a return — this is usually what you want (cleanup always happens), but it's easy to be surprised the first time by an ensure block 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.