Custom Exception Classes

Published by Programming Line in Ruby. Last updated .

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 from StandardError is what makes a plain rescue (or rescue => 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 (or super(msg)) — forwards the message up to Exception#initialize so it actually gets stored; skip this and e.message silently loses your text.
  • raise CustomError, "text" — the common short form; only works cleanly if initialize accepts a single positional argument.
  • raise CustomError.new(args) — required once your initialize takes more than one argument, since raise Class, arg can 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

  1. raise CustomError, "text" is evaluated. Ruby calls CustomError.new("text") (or CustomError.exception("text"), which defaults to the same thing).
  2. Your class’s initialize runs, if you defined one; otherwise the inherited StandardError#initialize runs and stores the string as the message.
  3. If your initialize calls super, that call reaches Exception#initialize, which is what makes #message return something other than the class name.
  4. 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-level rescue) as it goes.
  5. Each rescue clause is checked in order using is_a? against the exception’s class. The first clause whose class matches (or is an ancestor of) the exception’s class wins.
  6. The exception object is bound to the variable named after => (commonly e), and the matching rescue body executes.
  7. Once the rescue body finishes, execution continues immediately after the end of the begin block — the code that raised the error does not resume, unless you explicitly use retry.

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 bare Exception.
  • Name exception classes ending in Error so their purpose is obvious at a glance in a rescue clause.
  • If you override initialize, always call super with the message you want #message to 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 rescue clauses 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 Exception in application code; it also catches things like SystemExit and NoMemoryError that you almost never want to intercept.
  • Avoid empty rescue bodies that swallow errors silently — at minimum log or re-raise so failures aren’t invisible.

Practice Exercises

  1. Define OutOfStockError < StandardError and a method purchase(item, quantity, in_stock) that raises it with a message like "Cannot buy 5 x Widget, only 2 in stock" when quantity > in_stock. Rescue it and print the message.
  2. Build a small hierarchy: PaymentError < StandardError as the base, with CardDeclinedError and InsufficientFundsError as subclasses. Write a charge(amount, balance) method that raises the appropriate one, then rescue each subclass with its own message so you can tell them apart.
  3. Give an exception class extra data: create ApiError < StandardError with an initialize(status_code, message) that stores status_code via attr_reader and passes message to super. Raise it with status_code: 404 and print both the code and the message from the rescued exception.

Summary

  • Custom exceptions are ordinary classes that inherit from StandardError so a plain rescue catches them.
  • Overriding initialize lets an exception carry extra data, but you must call super or the message is lost and #message falls back to the class name.
  • raise Class, "text" only works when initialize takes a single argument; use raise 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.
  • rescue clauses are tried top to bottom using is_a?, so order them from most specific to most general.
  • Never inherit from bare Exception, and never rescue it in ordinary application code.