Custom Exception Classes
Ruby’s built-in exceptions like ArgumentError or TypeError are great for generic failures, but real applications have their own failure modes: a bank account with insufficient funds, a form field that fails validation, an API call that times out. Custom exception classes let you name these failures precisely, attach your own data to them, and let calling code rescue exactly the kind of problem it knows how to handle. This lesson covers how to define, raise, and organize your own exception classes the idiomatic way.
Overview: Why Build Your Own Exception Classes
In Ruby, exceptions are just objects, and exception classes are just classes — there is nothing magical about raise and rescue beyond ordinary object-oriented dispatch. Every exception you raise is an instance of some class that ultimately descends from Exception, the root of the exception hierarchy. Ruby splits that hierarchy deliberately: Exception itself covers everything, including low-level signals like NoMemoryError, SystemExit, and Interrupt that represent conditions your program usually should not try to handle. StandardError is the branch that covers ordinary, expected-to-happen-sometimes failures, and it is the class that a bare rescue clause catches by default. This is precisely why every custom exception class you write should inherit from StandardError, not from Exception directly — inheriting from Exception means an unqualified rescue silently fails to catch your error, and your error ends up lumped in with things like interrupt signals.
Because a custom exception class is just a class, you get all the normal object-oriented tools: you can override initialize to accept extra arguments, add attr_reader methods to expose structured data, and build a hierarchy of related error types with plain inheritance. When you write raise MyError, "message", Ruby calls MyError.new("message") internally, so whatever your initialize method does with that argument determines what the resulting exception object looks like. If you don’t override initialize at all, your class simply inherits StandardError‘s own initialize, which stores the string you pass as the exception’s message and is retrievable later via #message.
| Class | Role |
|---|---|
Exception |
The root of everything raisable, including low-level conditions like SystemExit that a plain rescue deliberately skips. |
StandardError |
The class a bare rescue catches by default, and the class your custom errors should inherit from. |
ArgumentError, TypeError, RuntimeError, … |
Ruby’s own built-in StandardError subclasses for common failures. |
YourCustomError < StandardError |
Your application’s own error, living alongside Ruby’s built-ins in the same hierarchy. |
Syntax
class CustomError < StandardError
def initialize(msg = "Something went wrong")
super
end
end
# two common ways to raise it
raise CustomError # uses the default message
raise CustomError, "specific message" # overrides the default message
raise CustomError.new("specific message")
class CustomError < StandardError— inheriting fromStandardErroris what makes a plainrescue(orrescue => e) catch your error.def initialize(msg = "...")— an optional override so your class can supply a sensible default message or accept extra arguments beyond just a message.super(orsuper(msg)) — forwards the message up toException#initializeso it actually gets stored; skip this ande.messagesilently loses your text.raise CustomError, "text"— the common short form; only works cleanly ifinitializeaccepts a single positional argument.raise CustomError.new(args)— required once yourinitializetakes more than one argument, sinceraise Class, argcan only pass a single value through.
Examples
Example 1: A Minimal Custom Exception
The simplest custom exception is a one-line class with no body at all — it inherits everything it needs from StandardError.
class InsufficientFundsError < StandardError; end
def withdraw(balance, amount)
raise InsufficientFundsError, "Cannot withdraw #{amount}, balance is #{balance}" if amount > balance
balance - amount
end
begin
withdraw(100, 150)
rescue InsufficientFundsError => e
puts "Error: #{e.message}"
end
Output:
Error: Cannot withdraw 150, balance is 100
Because 150 > 100, withdraw raises InsufficientFundsError with an interpolated string. Since we never overrode initialize, the class simply uses StandardError‘s own constructor, which stores whatever string follows the class name as the message. The rescue clause matches because InsufficientFundsError is a StandardError, and e.message returns exactly the string we raised with.
Example 2: Adding Custom Data with initialize and super
Overriding initialize lets an exception carry structured data, not just a string, while still producing a normal #message.
class ValidationError < StandardError
attr_reader :field
def initialize(field, message = "is invalid")
@field = field
super("#{field} #{message}")
end
end
def validate_age(age)
raise ValidationError.new(:age, "must be a positive number") if age <= 0
age
end
begin
validate_age(-5)
rescue ValidationError => e
puts "Validation failed on #{e.field}: #{e.message}"
end
Output:
Validation failed on age: age must be a positive number
validate_age(-5) raises ValidationError.new(:age, "must be a positive number"). Inside initialize, @field is set to :age, then super is called explicitly with a built string, which hands that string to StandardError#initialize so it becomes the exception’s message. Note we had to use raise ValidationError.new(...) rather than raise ValidationError, ..., because our initialize takes two arguments and the short raise Class, arg form can only supply one.
Example 3: Building an Exception Hierarchy
Real applications usually want several related error types they can rescue either individually or as a group. Plain inheritance handles this naturally.
class ApplicationError < StandardError; end
class NetworkError < ApplicationError; end
class TimeoutError < NetworkError; end
def fetch_data(status)
case status
when :timeout
raise TimeoutError, "Request timed out after 30s"
when :offline
raise NetworkError, "No network connection"
else
"data"
end
end
[:timeout, :offline].each do |status|
begin
fetch_data(status)
rescue TimeoutError => e
puts "Timeout specifically: #{e.message}"
rescue NetworkError => e
puts "General network problem: #{e.message}"
rescue ApplicationError => e
puts "Application error: #{e.message}"
end
end
Output:
Timeout specifically: Request timed out after 30s
General network problem: No network connection
For :timeout, TimeoutError is raised, and since it matches the first rescue TimeoutError clause exactly, that branch runs. For :offline, NetworkError is raised; it is not a TimeoutError, so Ruby moves to the next rescue clause, rescue NetworkError, which matches. Because rescue clauses are tried top to bottom and match using is_a?, this three-level hierarchy lets a caller rescue as narrowly or as broadly as it needs — catching just timeouts, all network issues, or every application error at once.
How It Works Step by Step
raise CustomError, "text"is evaluated. Ruby callsCustomError.new("text")(orCustomError.exception("text"), which defaults to the same thing).- Your class’s
initializeruns, if you defined one; otherwise the inheritedStandardError#initializeruns and stores the string as the message. - If your
initializecallssuper, that call reachesException#initialize, which is what makes#messagereturn something other than the class name. - The resulting exception object is raised: normal execution stops at that point, and Ruby starts unwinding the call stack, looking for an enclosing
begin...rescue(or a method-levelrescue) as it goes. - Each
rescueclause is checked in order usingis_a?against the exception’s class. The first clause whose class matches (or is an ancestor of) the exception’s class wins. - The exception object is bound to the variable named after
=>(commonlye), and the matchingrescuebody executes. - Once the
rescuebody finishes, execution continues immediately after theendof thebeginblock — the code that raised the error does not resume, unless you explicitly useretry.
Common Mistakes
Mistake 1: Rescuing the Broad Class Before the Specific One
rescue clauses are tried in order, so putting a superclass before its subclass makes the subclass’s clause unreachable.
class ApplicationError < StandardError; end
class TimeoutError < ApplicationError; end
begin
raise TimeoutError, "timed out waiting for response"
rescue ApplicationError => e
puts "Application error: #{e.message}"
rescue TimeoutError => e
puts "Timeout: #{e.message}"
end
Output:
Application error: timed out waiting for response
Since TimeoutError is an ApplicationError, the first rescue clause already matches, so the second clause (meant to handle timeouts specifically) never runs — it is dead code. The fix is to order clauses from most specific to most general:
class ApplicationError < StandardError; end
class TimeoutError < ApplicationError; end
begin
raise TimeoutError, "timed out waiting for response"
rescue TimeoutError => e
puts "Timeout: #{e.message}"
rescue ApplicationError => e
puts "Application error: #{e.message}"
end
Output:
Timeout: timed out waiting for response
Mistake 2: Overriding initialize Without Calling super
If you override initialize and never call super, the message you think you’re setting never reaches Exception, and #message silently falls back to the class name.
class ConfigError < StandardError
def initialize(key)
@key = key
end
end
begin
raise ConfigError, "database_url"
rescue ConfigError => e
puts e.message
end
Output:
ConfigError
Ruby happily calls initialize("database_url"), which stores @key but never calls super, so Exception‘s internal message is never set. #message then falls back to the class name, "ConfigError" — the useful text is lost. Always call super with the message you want to keep:
class ConfigError < StandardError
def initialize(key)
@key = key
super("missing configuration key: #{key}")
end
end
begin
raise ConfigError, "database_url"
rescue ConfigError => e
puts e.message
end
Output:
missing configuration key: database_url
Mistake 3: Forgetting to Inherit from an Exception Class
A plain class that doesn’t descend from Exception cannot be raised directly; Ruby refuses with a TypeError.
class MyError
end
begin
raise MyError, "something went wrong"
rescue TypeError => e
puts "Cannot raise MyError: #{e.message}"
end
Output:
Cannot raise MyError: exception class/object expected
raise requires its first argument to be either an Exception instance or a class descending from Exception; a bare class MyError; end is neither, so Ruby raises TypeError before your error ever gets created. The fix is simply to inherit from StandardError:
class MyError < StandardError
end
begin
raise MyError, "something went wrong"
rescue MyError => e
puts "Custom error raised: #{e.message}"
end
Output:
Custom error raised: something went wrong
Best Practices
- Always inherit custom exceptions from
StandardError(or a subclass of it), never from bareException. - Name exception classes ending in
Errorso their purpose is obvious at a glance in arescueclause. - If you override
initialize, always callsuperwith the message you want#messageto return. - Give a whole application or gem one common base error (for example
MyApp::Error < StandardError) so callers can rescue everything you might raise with a single clause when they need to. - Order
rescueclauses from most specific to most general — a superclass clause listed first silently swallows its subclasses. - Attach structured data with
attr_reader(an error code, a field name, an offending value) instead of packing everything into the message string for a human to re-parse. - Never rescue bare
Exceptionin application code; it also catches things likeSystemExitandNoMemoryErrorthat you almost never want to intercept. - Avoid empty
rescuebodies that swallow errors silently — at minimum log or re-raise so failures aren’t invisible.
Practice Exercises
- Define
OutOfStockError < StandardErrorand a methodpurchase(item, quantity, in_stock)that raises it with a message like"Cannot buy 5 x Widget, only 2 in stock"whenquantity > in_stock. Rescue it and print the message. - Build a small hierarchy:
PaymentError < StandardErroras the base, withCardDeclinedErrorandInsufficientFundsErroras subclasses. Write acharge(amount, balance)method that raises the appropriate one, then rescue each subclass with its own message so you can tell them apart. - Give an exception class extra data: create
ApiError < StandardErrorwith aninitialize(status_code, message)that storesstatus_codeviaattr_readerand passesmessagetosuper. Raise it withstatus_code: 404and print both the code and the message from the rescued exception.
Summary
- Custom exceptions are ordinary classes that inherit from
StandardErrorso a plainrescuecatches them. - Overriding
initializelets an exception carry extra data, but you must callsuperor the message is lost and#messagefalls back to the class name. raise Class, "text"only works wheninitializetakes a single argument; useraise Class.new(args)for anything more complex.- Inheritance lets you build an error hierarchy, so callers can rescue narrowly (one specific error) or broadly (a shared base class) as needed.
rescueclauses are tried top to bottom usingis_a?, so order them from most specific to most general.- Never inherit from bare
Exception, and never rescue it in ordinary application code.
