Custom Exception Classes

Reviewed & published by Brayan K

A custom exception class is your own error type — usually a subclass of StandardError — that gives failures a meaningful name and can carry extra data for the code that rescues it.

Part of the free Ruby course at LearnCodingFast — hands-on lessons with worked examples and the output they print, plus practice exercises and a quick quiz.

You'll define error classes, raise and rescue them by type, attach attributes, and use retry and ensure.

What You'll Learn in This Lesson

1️⃣ Defining and Raising a Custom Error

Create an error type by subclassing StandardError — often that's the entire class body. Then raise YourError, "message" to signal it, and rescue YourError => e to catch exactly that type while letting unrelated errors pass through.

# Define a custom exception by subclassing StandardError.
class ValidationError < StandardError
end

def check_age(age)
  raise ValidationError, "age must be positive" if age < 0
  "age #{age} ok"
end

begin
  puts check_age(25)
  puts check_age(-3)
rescue ValidationError => e
  puts "Caught: #{e.message}"
  puts "Class: #{e.class}"
end

2️⃣ Attributes, Messages & Rescuing by Type

Override initialize to store extra data and call super(message) so the message still works. Expose data with attr_reader, then read it in the handler. List multiple rescue clauses — most specific first — to handle each error type differently.

# A custom exception can carry extra data via attributes.
class HttpError < StandardError
  attr_reader :status

  def initialize(status, message = nil)
    @status = status
    super(message || "HTTP #{status}")
  end
end

def fetch(ok:)
  raise HttpError.new(404, "Not Found") unless ok
  "200 OK"
end

# Rescue different types separately:
begin
  fetch(ok: false)
rescue HttpError => e
  puts "#{e.status}: #{e.message}"
rescue StandardError => e
  puts "Other error: #{e.message}"
end

3️⃣ Recovering with retry and ensure

retry inside a rescue re-runs the begin block — ideal for transient failures, as long as you cap the attempts. ensure runs whether or not an exception happened, making it the right home for cleanup.

# retry, ensure, and a real recovery flow.
class FlakyError < StandardError; end

attempts = 0

begin
  attempts += 1
  puts "Attempt #{attempts}..."
  raise FlakyError, "temporary glitch" if attempts < 3
  puts "Success on attempt #{attempts}"
rescue FlakyError => e
  if attempts < 3
    puts "  retrying after: #{e.message}"
    retry              # jump back to begin
  else
    puts "  giving up"
  end
ensure
  puts "Cleanup always runs (attempts: #{attempts})"
end

Your turn. Finish a tiny bank withdrawal that raises a custom error when funds are short. Fill in each ___.

# 🎯 YOUR TURN — replace each ___ then run with: ruby bank.rb
class WithdrawalError < StandardError; end

def withdraw(balance, amount)
  # 👉 raise WithdrawalError if amount is more than balance
  raise WithdrawalError, "insufficient funds" if amount > ___
  balance - amount
end

begin
  puts withdraw(100, 30)
  puts withdraw(70, 200)
rescue ___ => e            # 👉 rescue the custom error type
  puts "Denied: #{e.message}"
end

# ✅ Expected output:
#    70
#    Denied: insufficient funds

Mini-Challenge: Email Registration

Build an InvalidEmailError that stores the offending address, then process a list so one bad email doesn't halt the rest. This per-item rescue pattern is everywhere in real systems. Run with ruby register.rb.

# 🎯 MINI-CHALLENGE: email registration.
# Save as register.rb and run with: ruby register.rb
#
# 1. Define InvalidEmailError < StandardError that stores the bad
#    email in an attr_reader and builds its own message via super.
# 2. Write register(email) that raises it when "@" is missing.
# 3. Loop over three addresses, rescuing the error per-address so
#    one bad email doesn't stop the others.
#
# ✅ Example output:
#    registered ada@example.com
#    Skipped (bademail): invalid email: bademail
#    registered bob@site.org

# your code here

Common Errors (and the fix)

Pro Tips

📋 Quick Reference — Exceptions

ConceptRuby Syntax
Defineclass MyError < StandardError
Raiseraise MyError, "msg"
Rescue typerescue MyError => e
Read messagee.message
Retryretry
Always runensure ... end

🎉 Lesson Complete!

Practice quiz

Which class should a custom exception usually inherit from?

  • Exception
  • RuntimeError only
  • StandardError
  • Object

Answer: StandardError. Inherit from StandardError so a bare rescue catches it, without swallowing system-level signals.

Why NOT inherit directly from Exception?

  • A bare rescue won't catch it, and you risk swallowing signals like Ctrl-C
  • It is slower
  • It can't store a message
  • It is deprecated

Answer: A bare rescue won't catch it, and you risk swallowing signals like Ctrl-C. A bare rescue catches StandardError, not all of Exception. Inheriting from Exception means rescue misses it and may swallow SignalInterrupt or SystemExit.

How do you attach extra data, like a status code, to a custom exception?

  • You cannot
  • Use a global variable
  • Pass it to rescue
  • Define initialize, store it in an ivar, expose with attr_reader, and call super(message)

Answer: Define initialize, store it in an ivar, expose with attr_reader, and call super(message). Override initialize to store the data, expose it with attr_reader, and call super(message) so the message machinery still works.

What does `retry` do inside a rescue clause?

  • Re-raises the error
  • Jumps back to the start of the begin block
  • Skips to ensure
  • Exits the program

Answer: Jumps back to the start of the begin block. retry restarts the begin block — useful for transient failures, as long as you cap attempts.

When does the `ensure` clause run?

  • No matter what — success or failure
  • Only on success
  • Only on error
  • Only when retry is used

Answer: No matter what — success or failure. ensure always runs, making it the right home for cleanup like closing files.

When listing multiple rescue clauses, which order is correct?

  • Most general first
  • Alphabetical
  • Most specific first, then general
  • Order doesn't matter

Answer: Most specific first, then general. Ruby checks rescues top to bottom; a broad StandardError first would shadow specific ones, so put specific first.

In a custom initialize, what happens if you forget to call `super(message)`?

  • The class won't load
  • The standard message machinery is lost
  • retry stops working
  • ensure runs twice

Answer: The standard message machinery is lost. Without super(message), the exception's message handling is lost.

What does `raise ValidationError, "age must be positive"` do?

  • Defines the error class
  • Rescues the error
  • Returns nil
  • Signals the error with that message

Answer: Signals the error with that message. raise signals the exception and creates the object with the given message.

To catch a whole family of your app's errors with one rescue, you should...

  • Rescue Exception
  • Create a base AppError < StandardError and subclass it
  • Use retry
  • Rescue NilClass

Answer: Create a base AppError < StandardError and subclass it. A base AppError < StandardError lets one `rescue AppError` catch every subclass in the family.

What does an uncapped `retry` risk?

  • A syntax error
  • Swallowing the message
  • Looping forever on a persistent error
  • Running ensure twice

Answer: Looping forever on a persistent error. Without an attempt counter, retry can loop forever if the error keeps recurring.

Continue this course

Frequently asked questions

Why should I inherit from StandardError and not Exception?

A bare rescue (without naming a class) catches StandardError and its subclasses — not all of Exception. Exception's other subclasses include things you generally should not swallow, like SignalInterrupt (Ctrl-C), NoMemoryError, and SystemExit. By inheriting your custom error from StandardError, it gets caught by ordinary rescue clauses as users expect, while those critical system-level exceptions still propagate.

How do I attach extra data, like a status code, to an exception?

Add attributes to your exception class. Define an initialize that stores the data in an instance variable, expose it with attr_reader, and call super(message) so the standard message machinery still works. Then in the rescue clause you can read those attributes off the rescued object (e.g. e.status). This lets the handler make decisions based on structured data, not just a string.

What's the difference between raise, rescue, retry, and ensure?

raise signals an error (and creates the exception object). rescue catches a matching exception and runs recovery code. retry, used inside a rescue, jumps back to the start of the begin block to try again — useful for transient failures. ensure runs no matter what — exception or not — so it's the place for cleanup like closing files or releasing locks.

Can I rescue several specific exception types differently?

Yes. List multiple rescue clauses, most specific first. Ruby checks them top to bottom and runs the first whose class matches (using is_a? semantics), so put HttpError before StandardError. You can also catch several types in one clause with rescue TypeA, TypeB => e. Ordering matters: a broad StandardError clause placed first would shadow the more specific ones below it.