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
- Defining a class that inherits from StandardError
- Why StandardError, not Exception
- Raising and rescuing specific error types
- Custom messages and extra attributes via super
- Recovering with retry
- Guaranteed cleanup with ensure
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}"
end2️⃣ 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}"
end3️⃣ 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})"
endYour 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 fundsMini-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 hereCommon Errors (and the fix)
- Inheriting from Exception — ❌ A bare rescue won't catch it. ✅ Inherit from StandardError.
- Forgetting super — ❌ A custom initialize without super loses the message. ✅ Call super(message).
- Broad rescue first — ❌ A rescue StandardError placed before specific ones shadows them. ✅ Order specific → general.
- retry with no limit — ❌ Endless retry can loop forever. ✅ Cap attempts with a counter.
Pro Tips
- 💡 Group errors: create a base AppError < StandardError and subclass it, so one rescue AppError catches the whole family.
- 💡 Method-level rescue: you can put rescue/ensure in a method body without an explicit begin.
- 💡 Re-raise: a bare raise inside a rescue re-raises the current exception after logging.
📋 Quick Reference — Exceptions
| Concept | Ruby Syntax |
|---|---|
| Define | class MyError < StandardError |
| Raise | raise MyError, "msg" |
| Rescue type | rescue MyError => e |
| Read message | e.message |
| Retry | retry |
| Always run | ensure ... end |
🎉 Lesson Complete!
- ✅ Define errors as subclasses of StandardError
- ✅ raise to signal, rescue Type => e to catch by type
- ✅ Carry extra data with attributes + super(message)
- ✅ retry re-runs the block; cap your attempts
- ✅ ensure always runs — perfect for cleanup
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
- Previous: Pattern Matching (case/in)
- Next: Dates & Times — Time, Date and DateTime — strftime formatting, parsing, and arithmetic
- Quick reference: Ruby cheat sheet
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.