Ruby

Custom Exceptions in Ruby: Classes, Messages & Hierarchies

Custom Exceptions in Ruby: Classes, Messages & Hierarchies

To create a custom exception in Ruby, define a class that inherits from StandardError: class PaymentError < StandardError; end. Raise it with raise PaymentError, "card declined" and rescue it by class. Inherit from StandardError, not Exception, so a bare rescue still catches it. Pass a default message by overriding initialize and calling super.

Ruby’s built-in exception classes describe the language’s failure modes, not your application’s. A PaymentError tells you more than a RuntimeError ever will — and when an error report lands in your logs or your debugging tooling, the class name is the first thing you read.

This article defines custom exception classes on Ruby 3.4, gives them useful messages and contextual data, and organizes them into hierarchies your rescue clauses can work with.

How to Define a Custom Exception in Ruby

Almost everything in Ruby is an object, and errors are no exception. A custom exception is an ordinary class that inherits from StandardError — one line is a complete, working error type:

Ruby
class PaymentError < StandardError; end

raise accepts the class with an optional message string:

Ruby
class PaymentError < StandardError; end
 
raise PaymentError, "card declined"

Uncaught, this stops the program and prints the message with your class name:

Shell
pay.rb:3:in '<main>': card declined (PaymentError)

raise PaymentError.new("card declined") — passing an exception instance instead of a class and message — produces exactly the same result. The two-argument form is the common idiom; the instance form is handy when you build the exception in one place and raise it in another.

A custom exception is rescued by class name, like any built-in error:

Ruby
begin
  charge_customer # Any code that can raise PaymentError.
rescue PaymentError => e
  puts e.message
end

Why Inherit From StandardError, Not Exception

Exception is the root of Ruby’s exception tree, but it is the wrong superclass for application errors. An abridged view of the Ruby 3.4 hierarchy shows why:

Shell
Exception
├── NoMemoryError
├── SignalException
│   └── Interrupt
├── SystemExit
└── StandardError
    ├── ArgumentError
    ├── NameError
    │   └── NoMethodError
    ├── RuntimeError
    └── TypeError

The classes that descend directly from Exception signal events your code usually shouldn’t intercept: Interrupt is Ctrl-C, SystemExit is exit doing its job. Application-level failures all live under StandardError, and that’s the branch Ruby’s rescue machinery is built around: a bare rescue clause — one that names no class — catches StandardError and its descendants.

Ruby
begin
  raise PaymentError, "card declined"
rescue => e
  puts e.class # PaymentError
end

Because PaymentError inherits from StandardError, the bare rescue catches it. Subclass Exception directly and your error slips past every normal rescue block in the codebase:

Ruby
class CriticalError < Exception; end
 
begin
  raise CriticalError, "internal failure"
rescue => e
  puts "rescued" # never printed
end
Shell
crit.rb:4:in '<main>': internal failure (CriticalError)

The rescue clause never runs, and the program crashes. Unless you are building something that genuinely must escape normal error handling, inherit from StandardError — or from a more specific built-in subclass like ArgumentError when your error refines its meaning. The full class list is in the Ruby 3.4 Exception docs, and our guide to rescuing exceptions in Ruby covers the rescue side in depth — multiple classes per clause, retries, and why rescue Exception causes trouble.

Custom Error Messages: super, message, and Defaults

Raise a custom exception without a message, and Ruby uses the class name as the message:

Ruby
class PaymentError < StandardError; end
 
raise PaymentError
Shell
pay.rb:3:in '<main>': PaymentError (PaymentError)

For an error with a sensible default, override initialize with a default argument and hand the message to super:

Ruby
class PaymentError < StandardError
  def initialize(msg = "payment could not be processed")
    super
  end
end
 
begin
  raise PaymentError
rescue => e
  puts e.message # payment could not be processed
end
 
begin
  raise PaymentError, "card declined"
rescue => e
  puts e.message # card declined
end

The bare super passes msg along to StandardError, so callers can still override the default with their own message.

The alternative is overriding the message method itself:

Ruby
class GatewayError < StandardError
  def message
    "the payment gateway did not respond"
  end
end
 
begin
  raise GatewayError, "this message is ignored"
rescue => e
  puts e.message # the payment gateway did not respond
end

A message override always wins — even a message passed at the raise site is discarded. Reach for it when the message should be fixed; use initialize plus super when the default should be overridable.

Adding Context to a Custom Exception

An exception is a full Ruby object, so it can carry structured data alongside its message — an error code, the record involved, the original failure. Define readers for the extra attributes and store them in initialize:

Ruby
class CustomException < StandardError
  attr_reader :code, :details
 
  def initialize(code, msg, details)
    @code = code
    @details = details
    super(msg)
  end
end

The super(msg) call is load-bearing. Skip it, and the message you pass in never reaches Exception’s internals — e.message falls back to the class name and returns "CustomException", exactly the bug an earlier version of this article shipped. When initialize takes more than one argument, call super(msg) explicitly with the message alone; a bare super would forward all the arguments.

Raising it from a rescue clause captures the original error as one of the attributes:

Ruby
def charge_customer
  nil.object # Any code that raises an exception.
rescue => exception
  raise CustomException.new(
    333,
    "A new exception occurred in the application",
    exception
  )
end

Whoever rescues CustomException now gets the message, the code, and the original exception:

Ruby
begin
  charge_customer
rescue CustomException => e
  e.message # => "A new exception occurred in the application"
  e.code    # => 333
  e.cause   # => #<NoMethodError: undefined method 'object' for nil>
end

That last line works without our details attribute: whenever you raise a new exception inside a rescue clause, Ruby stores the exception being handled in #cause automatically. The details attribute is still useful for context that isn’t an exception — a request ID, the payload that failed validation.

Two built-in methods surface this context when you log a rescued exception. #detailed_message appends the class name to the message:

Ruby
e.detailed_message
# => "A new exception occurred in the application (CustomException)"

And #full_message renders the complete report the default handler would print — message, class, and backtrace:

Ruby
class PaymentError < StandardError; end
 
def charge
  raise PaymentError, "card declined"
end
 
begin
  charge
rescue => e
  puts e.full_message
end
Shell
charge.rb:4:in 'Object#charge': card declined (PaymentError)
	from charge.rb:8:in '<main>'

When the exception has a #cause, full_message prints both traces, the wrapping error first — so the NoMethodError behind a CustomException still shows up in your logs. If you report errors to AppSignal, you can attach the same kind of contextual data to error reports; the exception handling documentation shows how.

Building an Exception Hierarchy for Your App

Custom exceptions get more useful in groups. Take an image uploader in a Rails app that accepts only JPEGs between 100 kilobytes and 10 megabytes. Each rule violation gets its own error class, and the two size errors share a parent:

Ruby
class ImageHandler
  # Domain-specific errors
  class ImageExtensionError < StandardError; end
  class ImageDimensionError < StandardError; end
  class ImageTooBigError < ImageDimensionError
    def message
      "Image is too big"
    end
  end
  class ImageTooSmallError < ImageDimensionError
    def message
      "Image is too small"
    end
  end
 
  def self.handle_upload(image)
    raise ImageTooBigError if image.size > 10.megabytes
    raise ImageTooSmallError if image.size < 100.kilobytes
    raise ImageExtensionError unless %w[JPG JPEG].include?(image.extension)
 
    # ... process the image
  end
end

Namespacing the errors inside ImageHandler keeps them discoverable — the class that raises them owns them. The message overrides give the size errors user-facing text, and ImageDimensionError exists purely as a grouping point: nothing raises it directly.

The payoff comes at rescue time. A controller can handle the whole dimension family with one clause while treating a suspicious file extension differently:

Ruby
class ImageUploadController < ApplicationController
  def upload
    @image = params[:image]
    ImageHandler.handle_upload(@image)
 
    redirect_to :index, notice: "Image upload success!"
 
  rescue ImageHandler::ImageDimensionError => e
    render "edit", alert: "Error: #{e.message}"
 
  rescue ImageHandler::ImageExtensionError
    head :forbidden
  end
end

Rescuing the parent class ImageDimensionError catches both ImageTooBigError and ImageTooSmallError, and each one’s message override supplies the right alert text. The extension error returns a 403 Forbidden instead — a non-JPEG upload is a security concern, not a user mistake. A domain error class is also the natural thing to re-raise as when you rescue a low-level failure, clean up, and pass it along — a pattern covered in ensure, retry, and reraise exceptions in Ruby.

Gems use the same structure at scale. The mongo-ruby-driver gem defines a Mongo::Error base class with a subclass per failure mode, so applications can rescue one specific error, a family, or everything the driver raises.

Custom exception classes pay off most in production: AppSignal’s Ruby error tracking groups incidents by exception class, so a PaymentError hierarchy turns a wall of RuntimeErrors into named failures you can route, mute, and alert on per class.

For the full production story — where these reports go and how to route them — see error reporting for Rails exceptions.

Common Errors When Raising Custom Exceptions

Two errors come up again and again while writing custom exception classes. Both messages here are verbatim Ruby 3.4 output.

exception class/object expected (TypeError)

Ruby
class Payment; end
 
raise Payment, "card declined"
Shell
payment.rb:3:in 'Kernel#raise': exception class/object expected (TypeError)

raise Payment, "card declined"
      ^^^^^^^^^^^^^^^^^^^^^^^^
	from payment.rb:3:in '<main>'

Cause: raise accepts an Exception subclass, an exception instance, or a string (which becomes a RuntimeError). Anything else — a plain class like Payment here, an integer like raise 42 — fails with this TypeError before any of your error handling runs.

Fix: Make the class you’re raising inherit from StandardError:

Ruby
class PaymentError < StandardError; end

uninitialized constant (NameError)

Ruby
begin
  raise "the payment failed"
rescue StandardException => e
  puts "rescued"
end
Shell
checkout.rb:3:in '<main>': uninitialized constant StandardException (NameError)

rescue StandardException => e
       ^^^^^^^^^^^^^^^^^
Did you mean?  StandardError
checkout.rb:2:in '<main>': the payment failed (RuntimeError)

Cause: The rescue clause names an exception class that doesn’t exist — there is no StandardException in Ruby; the constant has never existed. Ruby resolves the constant in a rescue clause only when an exception is raised, so the typo hides until something goes wrong. The resulting NameError then replaces the error you meant to handle — the original RuntimeError appears after it as the cause.

The un-namespaced form of the same mistake: raise ImageTooBigError from outside the ImageHandler class fails with uninitialized constant ImageTooBigError (NameError).

Fix: Reference an exception class that exists, by its full constant path — StandardError, ImageHandler::ImageTooBigError.

Wrapping Up and Next Steps

A custom exception in Ruby is a one-line class: inherit from StandardError, raise it, rescue it by name. Your reward is error reports that name the real problem.

Happy coding!

P.S. If you’d like to read Ruby Magic posts as soon as they get off the press, subscribe to our Ruby Magic newsletter and never miss a single post!

Frequently asked questions

How do you create a custom exception in Ruby?
Define a class that inherits from StandardError, for example class PaymentError < StandardError; end. Raise it with raise PaymentError or raise PaymentError, followed by a message string, and rescue it by class name like any built-in Ruby exception.
Should a custom Ruby exception inherit from StandardError or Exception?
Inherit from StandardError. A bare rescue clause only catches StandardError and its descendants, so exceptions built on it behave the way calling code expects. Subclassing Exception directly means your error escapes normal rescue blocks and gets caught alongside signals and system-level failures.
How do you add a default message to a custom exception in Ruby?
Override initialize with a default message and pass it to super. If initialize never calls super with a message, Ruby falls back to the class name — raising PaymentError with no message then reports the string PaymentError instead of anything useful.
What happens if you raise a class that does not inherit from Exception?
Ruby raises TypeError with the message exception class/object expected. The raise keyword accepts an Exception subclass, an exception instance, or a string; anything else, like a plain class or an integer, fails with that TypeError before your error handling runs.

Published , Updated

Wondering what you can do next?

  • Share this article on social media
Brena Monteiro

Brena Monteiro

Guest author Brena is a Tech Lead passionate about mentoring new developers and experienced in developing and leading high-performing teams. She has experience in building scalable APIs and integrations between cloud services, is enthusiastic about evolutionary architecture, and is an Extended Reality (XR) Apprentice.

All articles by Brena Monteiro

Become our next author!

Find out more
$appsignal install

AppSignal monitors your apps

AppSignal provides insights for Ruby, Rails, Elixir, Phoenix, Node.js, Express and many other frameworks and libraries. We are located in beautiful Amsterdam. We love stroopwafels. If you do too, let us know. We might send you some!

Discover AppSignal