The Exception Class Catch and Throw in Ruby: Error Handling and Control Flow Explained

The Exception Class Catch and Throw in ruby

The Exception Class Catch and Throw in ruby

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:

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:

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

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

Exit mobile version