Skip to content

Ruby Exception Handling — begin rescue ensure raise and Custom Exceptions

DodaTech Updated 2026-06-28 8 min read

In this tutorial, you will learn about Ruby Exception Handling. We cover key concepts, practical examples, and best practices to help you master this topic.

Ruby exception handling uses begin/rescue/ensure blocks for error recovery, raise for triggering errors, and custom exception classes for domain-specific error conditions.

What You'll Learn

  • The begin/rescue/ensure/end structure
  • Rescuing specific exception classes
  • Raising custom exceptions
  • The ensure block for cleanup
  • Best practices for error handling

Why It Matters

Robust error handling separates production code from prototypes. Durga Antivirus Pro must handle corrupt files, permission errors, and network timeouts gracefully. DodaZIP catches compression errors and continues processing remaining files. Doda Browser handles network failures without crashing. Exception handling is how professional Ruby code stays reliable.

Real-World Use

Rails controllers rescue ActiveRecord::RecordNotFound to return 404s. Background Jobs rescue errors to retry later. API clients rescue network errors and provide meaningful messages. Every Ruby application needs strategic error handling.

flowchart LR
    A["Exceptions"] --> B["begin/rescue"]
    B --> C["Multiple Rescues"]
    C --> D["Ensure"]
    D --> E["Raise"]
    E --> F["Custom Classes"]
    A:::current --> B
    style A fill:#2563eb,stroke:#2563eb,color:#fff
    style B fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style C fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style D fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style E fill:#dbeafe,stroke:#2563eb,color:#1e40af
    style F fill:#f1f5f9,stroke:#94a3b8,color:#64748b

Basic begin/rescue

begin
  result = 10 / 0
  puts result
rescue ZeroDivisionError => e
  puts "Cannot divide by zero: #{e.message}"
end
# Cannot divide by zero: divided by 0

Rescuing Specific Exceptions

begin
  File.read("missing.txt")
rescue Errno::ENOENT => e
  puts "File not found: #{e.message}"
rescue Errno::EACCES => e
  puts "Permission denied: #{e.message}"
rescue => e  # Catch-all
  puts "Unknown error: #{e.class}: #{e.message}"
end

Rescue Without begin

You can rescue at the method level:

def divide(a, b)
  a / b
rescue ZeroDivisionError
  "Cannot divide by zero"
end

puts divide(10, 2)  # 5
puts divide(10, 0)  # Cannot divide by zero

Inline Rescue

# Risky — catches StandardError, too broad
result = risky_operation rescue "fallback"

# Better — more specific
result = begin
  risky_operation
rescue SomeSpecificError
  "fallback"
end

The Ensure Block

ensure runs whether or not an exception was raised:

file = nil
begin
  file = File.open("data.txt", "r")
  content = file.read
rescue Errno::ENOENT => e
  puts "File not found"
ensure
  file.close if file
  puts "File closed"
end

Ensure with Return

def test_ensure
  begin
    return "from begin"
  ensure
    puts "ensure runs even after return"
  end
end

puts test_ensure
# ensure runs even after return
# from begin

Raising Exceptions

# Simple raise
raise "Something went wrong"

# With exception class
raise ArgumentError, "Invalid argument"

# With existing exception
begin
  raise "Original error"
rescue => e
  raise RuntimeError, "Wrapped: #{e.message}"
end

raise vs fail

raise and fail are synonyms:

raise "error"  # Most common
fail "error"   # Same thing

Custom Exception Classes

class ApplicationError < StandardError; end
class ValidationError < ApplicationError; end
class AuthenticationError < ApplicationError; end
class NotFoundError < ApplicationError; end

def find_user(id)
  raise NotFoundError, "User #{id} not found" unless id == 1
  { id: 1, name: "Alice" }
end

begin
  user = find_user(99)
rescue NotFoundError => e
  puts "Error: #{e.message}"
end
# Error: User 99 not found

Custom Exceptions with Additional Data

class ValidationError < StandardError
  attr_reader :field, :value

  def initialize(field, value, message = nil)
    @field = field
    @value = value
    super(message || "Invalid value for #{field}: #{value.inspect}")
  end
end

def validate_age(age)
  raise ValidationError.new(:age, age, "Age must be positive") if age < 0
  raise ValidationError.new(:age, age) if age > 150
  true
end

begin
  validate_age(-5)
rescue ValidationError => e
  puts "#{e.field}: #{e.message}"
end
# age: Age must be positive

Exception Hierarchy

# Key exception classes
# BasicObject
#   Exception
#     NoMemoryError
#     ScriptError
#       LoadError
#       NotImplementedError
#       SyntaxError
#     SignalException
#       Interrupt
#     StandardError  <-- Most common rescue target
#       ArgumentError
#       IOError
#         EOFError
#       IndexError
#         KeyError
#         StopIteration
#       NameError
#         NoMethodError
#       RangeError
#         FloatDomainError
#       RuntimeError
#       TypeError
#       ZeroDivisionError
#     SystemExit
#     SystemStackError

Retrying After Failure

attempts = 0
begin
  attempts += 1
  puts "Attempt #{attempts}"
  raise "Failed" if attempts < 3
  puts "Success"
rescue
  retry if attempts < 3
end
# Attempt 1
# Attempt 2
# Attempt 3
# Success

The Else Block

else runs when no exception occurs:

begin
  result = 10 / 2
rescue ZeroDivisionError => e
  puts "Error: #{e.message}"
else
  puts "Success: #{result}"
end
# Success: 5

Catch and Throw (Non-Local Exit)

For control flow (not error handling):

catch(:done) do
  [1, 2, 3, 4, 5].each do |n|
    throw(:done, "Found 3") if n == 3
  end
end
# "Found 3"

Exception Information

begin
  raise ArgumentError, "bad input"
rescue => e
  puts e.message     # "bad input"
  puts e.class       # ArgumentError
  puts e.backtrace   # Array of strings
  puts e.full_message # Formatted message with backtrace
end

Common Mistakes

1. Rescuing Exception (Too Broad)

# Wrong — catches everything including SignalException, SystemExit
begin
  something_risky
rescue Exception  # Too broad!
  puts "Error"
end

# Right — catch StandardError or specific classes
begin
  something_risky
rescue StandardError => e
  puts "Error: #{e.message}"
end

2. Swallowing Exceptions Silently

# Bad — you'll never know something went wrong
begin
  something_risky
rescue
  # Do nothing
end

# Better — at least log it
begin
  something_risky
rescue => e
  logger.error "Operation failed: #{e.message}"
end

3. Using Rescue as Control Flow

# Bad — exception handling is slow
begin
  Integer(input)
rescue ArgumentError
  nil
end

# Better — use check methods
Integer(input, exception: false)  # Ruby 2.6+

4. Forgetting to Re-raise

def process_file(path)
  begin
    data = File.read(path)
    transform(data)
  rescue Errno::ENOENT => e
    logger.error "File not found: #{path}"
    raise  # Re-raise the same exception
  end
end

5. Ordering Rescue Blocks Wrong

begin
  raise ArgumentError
rescue StandardError => e  # Catches everything first!
  puts "Standard error"
rescue ArgumentError => e  # Never reached
  puts "Argument error"
end
# Always put more specific exceptions first

Practice Questions

1. What's the difference between rescue and ensure?

rescue catches exceptions and handles recovery. ensure runs cleanup code regardless of whether an exception occurred. Use ensure for releasing resources (files, connections).

2. Why shouldn't you rescue Exception?

Exception is the root class. Rescuing it catches system-level exceptions like SignalException (Ctrl+C), NoMemoryError, and SystemExit — which should terminate the program.

3. What does retry do in a rescue block?

retry restarts the begin block from the beginning. Use it for transient failures with a retry limit. Without a limit, it loops forever.

4. How do you create a custom exception class?

Inherit from StandardError (or a more specific class): class MyError < StandardError; end. Add attributes if needed: constructors, reader methods, and custom messages.

Challenge: Create a robust file reader that retries up to 3 times with a 1-second delay between attempts, and returns nil if all attempts fail.

Solution
def robust_read(path, max_retries: 3)
  retries = 0
  begin
    File.read(path)
  rescue Errno::ENOENT => e
    retries += 1
    if retries <= max_retries
      puts "Retry #{retries}/#{max_retries}: #{path}"
      sleep 1
      retry
    else
      puts "Failed after #{max_retries} retries"
      nil
    end
  rescue Errno::EACCES => e
    puts "Permission denied: #{path}"
    nil
  end
end

data = robust_read("/tmp/maybe_file.txt")
puts data.nil? ? "No data" : data.length

FAQ

{{< faq question="What's the difference between raise and fail?" >}} Nothing. raise and fail are aliases for the same method. Most Ruby developers use raise. Some prefer fail when the exception is expected or part of normal control flow. {{< /faq >}}

{{< faq question="Should I use exceptions for validation errors?" >}} Depends on context. In library code, raise exceptions for invalid input. In application code, use validation objects or return objects with error states. Rails uses exceptions for framework-level errors and validation objects for form errors. {{< /faq >}}

{{< faq question="Can I rescue an exception outside the begin block?" >}} Yes. Method-level rescue blocks catch exceptions from any code in the method body without an explicit begin block. This is the most common pattern in Ruby. {{< /faq >}}

{{< faq question="What happens if ensure has a return statement?" >}} If ensure has a return, it overrides the exception and any previous return value. This is almost always a bug. Use ensure only for cleanup, not control flow. {{< /faq >}}

{{< faq question="How do I add context to a re-raised exception?" >}} Use a custom message and set the cause: raise MyError.new("context"), cause: original_exception. Or wrap: raise "Failed: #{e.message}" (the original exception's cause is preserved in Ruby 3.x). {{< /faq >}}

Try It Yourself

# exceptions_demo.rb

class FileProcessor
  class ProcessingError < StandardError; end

  def self.process(path)
    raise ProcessingError, "Path is nil" if path.nil?
    raise ProcessingError, "File not found: #{path}" unless File.exist?(path)

    content = File.read(path)
    raise ProcessingError, "Empty file" if content.strip.empty?

    content.upcase
  rescue ProcessingError => e
    puts "Processing error: #{e.message}"
    nil
  rescue => e
    puts "Unexpected error: #{e.class}: #{e.message}"
    nil
  end
end

# Test
puts FileProcessor.process(nil)
puts FileProcessor.process("nonexistent.txt")
puts FileProcessor.process("/dev/null")

Expected output:

Processing error: Path is nil
Processing error: File not found: nonexistent.txt
Processing error: Empty file

What's Next

Now that you understand exception handling, learn about the Enumerable module in depth for powerful collection operations.

Topic Description Link
Ruby Enumerable each, map, select, reduce, group_by {{< ref "21-enumerable" >}}
Ruby Date & Time Time, Date, DateTime, strftime {{< ref "22-date-time" >}}
Python Exceptions Compare Python exception handling Python

Built by the developers of DodaTech

Doda Browser, DodaZIP & Durga Antivirus Pro