AlgoMaster Logo

Custom Exceptions

High Priority20 min readUpdated September 21, 2026
Listen to this chapter
Unlock Audio

Built-in exceptions like ValueError and KeyError cover common cases, but they say nothing about your domain. When a payment fails or a cart is empty, raising Exception("payment failed") works, but callers can't catch that without also catching every other generic error. Custom exceptions are how you give errors a meaningful name and shape that fits your application. This lesson covers what custom exceptions are, how to design a small hierarchy for an e-commerce app, how to attach context to them, and the conventions to follow so the result feels Pythonic.

Why Build Your Own

The standard library's exceptions are general on purpose. ValueError says "the value was wrong," but it doesn't say what was wrong about it. A function that processes an order might raise ValueError for a bad price, for a missing customer, and for a sold-out item, and the caller has no way to tell those apart without parsing the error message string. Parsing error strings is brittle and one of the surest ways to write code that breaks on the next library update.

A custom exception gives an error a typed identity. When you write class OutOfStockError(Exception): pass, you're telling Python "this is a specific kind of problem, distinct from every other kind." Callers can then write except OutOfStockError: and catch only that one kind, letting the rest propagate. The error type itself becomes part of the contract between the function that raises and the caller that handles.

There are three concrete reasons to introduce a custom exception:

  • Domain meaning. OutOfStockError reads better than Exception("item not available"). A reader of the calling code knows exactly what went wrong without inspecting the message.
  • Selective handling. A caller can catch one specific failure (InsufficientFundsError) and let everything else propagate. That's impossible if every failure is a generic Exception.
  • Context attachment. A custom exception class can carry structured data: the product that was out of stock, the requested quantity, the customer's balance. Built-in exceptions only carry a message string.

The cost is small. A custom exception class is often a single line. The payoff is code that names its failures clearly and can react to each one on its own terms.

The Minimal Custom Exception

Defining a custom exception is just defining a class that inherits from Exception. The body can be empty; the name and the inheritance are what give it its behavior.

That's the whole thing. OutOfStockError inherits everything from Exception: the ability to be raised, the message handling, the traceback formatting. The pass body just means "no additional behavior beyond what Exception provides."

You can catch it the same way you catch any built-in exception:

The except OutOfStockError clause catches only this specific class. If reserve_item later raised a different kind of error (ValueError, say, for a bad input), this except would let it propagate, which is usually what you want.

Two conventions worth picking up right away. The class name ends with Error (PEP 8 calls this out: exception names should match the pattern of built-ins like ValueError, TypeError, KeyError). And the class inherits from Exception, not from BaseException. There's a reason for that second one, covered below.

Inherit From Exception, Not BaseException

Python's exception hierarchy has two layers near the top. BaseException is the root of everything, and Exception is its immediate child. Almost every error you've seen (ValueError, KeyError, TypeError, RuntimeError) inherits from Exception. The other branch of BaseException, the one that isn't Exception, is reserved for things that should generally not be caught: KeyboardInterrupt (the user pressed Ctrl+C), SystemExit (the program is shutting down), GeneratorExit (internal generator cleanup).

The takeaway: always inherit from Exception (or one of its subclasses), never from BaseException directly. If you inherit from BaseException, your custom error won't be caught by the common except Exception: clause that wraps most application code, which is almost never what you want.

The wrong version slips past any except Exception: handler in the surrounding code, which means a single missing handler can crash your entire program even when it has a generic "catch and log" block at the top. Stick to Exception.

A Hierarchy for an E-Commerce App

A single custom exception is useful. A small hierarchy of related custom exceptions is where the design starts to pay off. The idea: pick one root class for your application's domain, group related errors under intermediate branches, and put the specific concrete errors at the leaves.

For a shop, a reasonable starting layout looks like this:

ShopError is the root: anything that fails because of the shop's own business rules is some kind of ShopError. Below it sit three branches, one for each broad area (carts, inventory, payments). At the leaves are the specific failures: an empty cart at checkout, an out-of-stock item, a customer whose card was declined for lack of funds.

The arrows point from parent to child. Every leaf is also an instance of its parent branch, and every branch is also an instance of ShopError. That's the key property the hierarchy gives you.

Why bother with the middle layer (CartError, InventoryError, PaymentError)? Because it gives callers three useful levels of granularity:

  • Catch one specific failure: except OutOfStockError.
  • Catch any inventory-related failure: except InventoryError.
  • Catch any shop-related failure: except ShopError.

A high-level request handler might use the broadest one, logging anything that's a ShopError and returning a generic "please try again" response. A business-logic function might use the most specific one, because it knows exactly how to recover from an OutOfStockError (suggest a similar item) but not from anything else.

A single except ShopError catches all three failure types. The handler can still tell them apart by checking type(e) or by adding more specific except clauses before the broad one. The point is that one type-aware except swept up everything domain-specific without accidentally catching unrelated errors (like a KeyError from a typo in a dict lookup somewhere).

Adding Context With __init__

A bare exception with a message string carries enough information to log, but not always enough to act on. If a payment fails because the card was declined, the caller might want to know the requested amount and the available balance to render a useful message. If an item is out of stock, the caller might want the product name and the quantity that was requested.

You can attach that data by giving the exception class its own __init__. Inside, you pass the message up to Exception with super().__init__(message), and you store the rest as attributes.

Two things happen in __init__. The super().__init__(message) call hands the formatted message string to Exception, which stores it where the traceback machinery and the str() of the exception expect to find it. Without that call, str(e) would return an empty string and the traceback would print without a useful message. Then the three self.attr = value lines attach the structured data, so callers can read e.product or e.available directly without parsing the string.

The pattern is the same one you'd use for any class with extra attributes: take the parameters, build a message, hand it up with super().__init__(...), store the rest on self. The only thing that makes this an exception class rather than a regular class is the inheritance from Exception.

A second example for payments:

The shortfall attribute is computed inside __init__ rather than passed in, because it's derived from the other two values. The caller never has to compute it; the exception knows.

Custom __str__ for Readable Output

Exception.__str__ returns whatever message string you passed to super().__init__. That's usually fine. But sometimes you want the printed form of the exception to look different from the message stored on the parent, or you want to format the structured attributes into a richer string.

You can override __str__ for that:

print(err) calls __str__, which returns the custom formatted string. repr(err) uses the default __repr__ inherited from Exception, which shows the class name and the arguments passed to super().__init__. The two views serve different purposes: __str__ is what humans see in logs and tracebacks, and __repr__ is what developers see in a debugger or REPL.

You can override __repr__ too if you want a more detailed developer-facing string, but it's rarely needed for exceptions; the default is usually good enough. Most of the customization happens in __str__ or in the constructor itself.

A subtle point: if you only call super().__init__(self.product) (passing just the product name) but your __str__ builds a longer message from all three attributes, the message stored on the parent and the message returned by str(self) no longer match. The traceback machinery uses __str__, so the longer string is what shows up at the bottom of a traceback, but e.args will still contain just (product,). Both behaviors are valid; just be deliberate about which one you want.

Calling Conventions and Subclass Patterns

When a custom exception has its own __init__, the rest of the codebase calls it with the parameters that __init__ declares, not with a free-form message string. That's actually one of the wins: the call site can't accidentally forget the structured data, because the constructor requires it.

The raise EmptyCartError(customer_id) call passes one positional argument, exactly what __init__ wants. The except CartError as e clause catches it because EmptyCartError inherits from CartError. Inside the handler, e.customer_id reads the structured attribute the constructor stored.

A common pattern for hierarchies: the intermediate branches stay empty (just pass), and only the leaf classes add their own __init__ and attributes. That keeps the design simple: you define the structure at the top, and the data shape lives with the specific error that needs it.

ShopError and InventoryError are pure structural classes; they don't carry their own data. OutOfStockError and DiscontinuedError are concrete and each one knows what attributes it needs. Callers can still catch any inventory problem with except InventoryError, and they can still inspect the specific type with type(e) or with separate except blocks for each leaf.

How Callers Use the Hierarchy

The reason to design a hierarchy is to give callers a useful menu of catch clauses. Different layers of your code will want different granularity, and the hierarchy lets each pick the level that fits.

A specific business-logic function might want to handle one error type and let the rest bubble up:

add_to_cart catches the very specific OutOfStockError because it has a meaningful response to that one failure (suggest a similar product). Any other ShopError it doesn't know how to handle propagates up to the caller.

A high-level entry point, on the other hand, often wants to catch every domain error in one place:

In this example, the inner add_to_cart already handled the OutOfStockError and returned a string, so the outer except never fired. But if any other ShopError had bubbled up (an InsufficientFundsError, say), the broad except ShopError would have caught it and produced a user-friendly message. And any non-ShopError exception would hit the generic except Exception for last-resort handling.

The pattern is: catch broad at the edges (request handlers, top-level functions), catch specific in the business logic where you know exactly how to react. The hierarchy makes both styles possible from the same set of classes.

Naming Conventions and Style

A handful of small conventions, all from PEP 8 or Python's standard library, will make your custom exceptions feel at home:

  • End the class name with `Error`. OutOfStockError, InsufficientFundsError, EmptyCartError. Built-ins follow the same pattern (ValueError, KeyError, RuntimeError), and matching it is what makes a class read as "a kind of error" at a glance. The only exceptions to the rule in the standard library are Warning subclasses and a handful of historical names like StopIteration.
  • Inherit from `Exception` or one of its subclasses. Never from BaseException directly. The reason is the one covered earlier: BaseException slips past except Exception: handlers.
  • Use a CapWords (PascalCase) class name. Like any class. out_of_stock_error or OUT_OF_STOCK_ERROR would be wrong.
  • Pick a meaningful root for your application's domain. ShopError, OrderError, PaymentError. Avoid a generic root name like AppError or MyException, which doesn't tell a reader anything about the domain.
  • Give each leaf class a docstring if its purpose isn't obvious from the name. The docstring shows up in help(ClassName) and serves as the contract for when the error is raised.

The docstrings are short, just enough to remove ambiguity for the next person who reads the code. They're also free documentation, because help(EmptyCartError) will show them.

Anti-Patterns to Avoid

A few patterns look tempting but produce hard-to-maintain code:

Subclassing `BaseException`. Already covered. Don't do it. Inherit from Exception (or a more specific built-in or a class in your own hierarchy).

One exception per code path. It's a smell to define InventoryReadFailedAtLine42Error. The class should describe a kind of problem, not a location in code. Twenty narrowly scoped exceptions are usually worse than three or four well-named ones with structured attributes.

Reusing built-ins for domain errors. Raising ValueError("cart is empty") works, but it leaks: any except ValueError in surrounding code catches your domain error along with everything else ValueError is used for. Define EmptyCartError instead.

No common ancestor. If OutOfStockError, InsufficientFundsError, and EmptyCartError each inherit directly from Exception, the only way to "catch any shop error" is to list all three in the except clause. Add a shared parent (ShopError) and the catch-all becomes a single name.

Putting logic in `__init__`. The constructor's job is to store the data and format the message. Don't do file I/O, network calls, or expensive lookups in there. If the exception is raised in a tight loop, the constructor runs every time, and slow constructors make error paths slower.

Forgetting to call `super().__init__(message)`. Without it, str(e) returns an empty string and tracebacks are unhelpful. Always pass a useful message up to the parent, even if your subclass also overrides __str__.

The class catches and raises fine, but str(err) is empty because Exception was never told what the message is. Add super().__init__(f"{product} is sold out") and the message appears.

A Brief Note on ExceptionGroup

Python 3.11 added ExceptionGroup, a built-in container for raising several exceptions at once. The use case is rare, but worth knowing about: it shows up when one operation runs multiple sub-operations in parallel (or in sequence with collection of errors) and several of them might fail independently. For an e-commerce app, you might use it when validating a whole cart and wanting to report every problem at once rather than the first one.

The raise line is commented out so the file runs, but it would raise both errors together. Callers catch them with except* (the star is new syntax, also 3.11+), which can match each sub-exception in the group separately. The mechanism is mostly used by libraries that fan out work to many concurrent tasks and need to report partial failures cleanly. For day-to-day custom exception design, raising one exception at a time is still the norm.

Quiz

Custom Exceptions Quiz

10 quizzes