Type something to search...
Creating Custom Exceptions in Python for Clearer Error Handling

Creating Custom Exceptions in Python for Clearer Error Handling

Python's built-in exceptions cover a lot of ground. ValueError, KeyError, TypeError, and OSError describe most low-level failures well. But once your code has its own concepts, such as payments, orders, configs, or API clients, built-in exceptions start to blur together. Was that ValueError from your validation layer or from some int() call three libraries deep? A caller can't tell, so it can't respond sensibly.

Custom exceptions fix that. They give your errors names that match your domain, let callers catch exactly the failures they care about, and carry structured data instead of a single string.

This post covers when a custom exception is worth defining, how to write one properly, how to design a small hierarchy for a package, how to attach extra data without breaking pickling or repr, and how to translate low-level errors at your module's boundary. I'll assume you're comfortable with try/except; if not, start with Exception Handling in Python: try, except, else, and finally.

When a Custom Exception Is Worth It

Not every error needs its own class. A good test is: would a caller want to catch this specific failure and do something different with it?

Define a custom exception when:

  • Callers need to distinguish your error from built-in ones raised by unrelated code.
  • There's a sensible recovery for it: retrying, showing a specific message, returning a particular HTTP status.
  • The error needs to carry structured data (a status code, a field name, an ID).
  • You're writing a library, and users need one base class to catch "anything this library raises on purpose".

Stick with a built-in when it already says exactly what happened. A function that receives a negative quantity should raise ValueError; inventing NegativeQuantityError usually adds a name without adding meaning. The same goes for TypeError on the wrong argument type and KeyError or LookupError for missing items.

The Simplest Custom Exception

A custom exception is a class that inherits from Exception:

class ConfigError(Exception):
    """Raised when the application config can't be loaded."""

That's a complete, working exception. The docstring serves as the body, so there's no need for pass. You raise it with a message like any other exception, and it behaves exactly like the built-ins:

raise ConfigError("config file not found: settings.json")

Two rules for the base class:

  • Inherit from Exception, not BaseException. BaseException is reserved for things like KeyboardInterrupt and SystemExit that aren't really errors. If you inherit from it directly, except Exception: handlers won't catch your error, which surprises everyone.
  • End the name with Error. That's the convention in the standard library (PEP 8 says so too), and it makes the purpose obvious at a glance.

Building an Exception Hierarchy

For a package or a subsystem, the most useful pattern is a single base exception with more specific subclasses under it:

# payments/errors.py
class PaymentError(Exception):
    """Base class for all errors raised by the payments package."""


class CardDeclinedError(PaymentError):
    """The card issuer refused the charge."""


class InsufficientFundsError(CardDeclinedError):
    """The card was declined for lack of funds."""


class GatewayTimeoutError(PaymentError):
    """The payment gateway didn't respond in time."""

Because except matches subclasses, callers can choose how specific to be:

from payments.errors import (
    CardDeclinedError,
    GatewayTimeoutError,
    InsufficientFundsError,
    PaymentError,
)


def charge(amount: int) -> None:
    if amount > 100:
        raise InsufficientFundsError(f"cannot charge {amount}")
    if amount < 0:
        raise GatewayTimeoutError("gateway timed out after 30s")


for amount in (500, -1):
    try:
        charge(amount)
    except CardDeclinedError as e:
        print("declined:", e)
    except PaymentError as e:
        print("payment problem:", type(e).__name__, e)

Output:

declined: cannot charge 500
payment problem: GatewayTimeoutError gateway timed out after 30s

InsufficientFundsError is caught by the CardDeclinedError handler because it's a subclass. GatewayTimeoutError isn't a decline, so it falls through to the general PaymentError handler.

A few guidelines for designing the tree:

  • Keep it shallow. One base class plus a handful of subclasses covers most packages. Deep hierarchies are hard to remember and rarely caught at every level.
  • Group by how callers respond, not by where the error came from. "Retry might help" vs "user must fix their input" is a more useful split than "raised in client.py" vs "raised in parser.py".
  • Export the exceptions publicly. Put them in an errors.py or exceptions.py module and re-export them from the package's __init__.py, so users can import them without digging. If you're new to how packages expose names, see What Are Python Modules and Packages?.

The base class is the most important piece. It gives users a single line, except PaymentError:, that catches every deliberate failure from your code while letting genuine bugs (a stray AttributeError, say) propagate.

Adding Extra Data to an Exception

A message string is fine for humans, but code often needs specifics. Store them as attributes.

The obvious approach works for raising and catching:

class ValidationError(Exception):
    def __init__(self, field: str, message: str) -> None:
        super().__init__(f"{field}: {message}")
        self.field = field
        self.message = message


try:
    raise ValidationError("email", "must contain '@'")
except ValidationError as e:
    print(e)        # email: must contain '@'
    print(e.field)  # email

It has a hidden problem, though. Exceptions are recreated from their args attribute when they're pickled, which happens when they cross a process boundary, for example coming back from a multiprocessing or concurrent.futures.ProcessPoolExecutor worker. Here args is just the formatted message, so unpickling calls ValidationError("email: must contain '@'") with one argument and fails:

TypeError: ValidationError.__init__() missing 1 required positional argument: 'message'

The Robust Pattern: Pass Everything to super() and Override __str__

Pass all the constructor arguments through to super().__init__() so args matches your signature, and build the human-readable message in __str__ instead:

# http_errors.py
class HTTPError(Exception):
    def __init__(self, status: int, url: str, body: str = "") -> None:
        self.status = status
        self.url = url
        self.body = body
        super().__init__(status, url, body)

    def __str__(self) -> str:
        return f"HTTP {self.status} for {self.url}"

    @property
    def retryable(self) -> bool:
        return self.status >= 500 or self.status == 429


try:
    raise HTTPError(503, "https://api.example.com/orders")
except HTTPError as e:
    print(e)
    print(repr(e))
    print(e.retryable)

Output:

HTTP 503 for https://api.example.com/orders
HTTPError(503, 'https://api.example.com/orders', '')
True

Now the exception has a clean message, a useful repr for logs and debuggers, and it survives a round-trip through pickle. The retryable property shows another benefit of custom exceptions: the error can carry logic about itself, so callers don't have to hard-code status-code rules.

Using a Dataclass

If an exception has several fields, a dataclass removes the boilerplate:

from dataclasses import dataclass


@dataclass
class OutOfStockError(Exception):
    sku: str
    requested: int
    available: int

    def __str__(self) -> str:
        return (f"only {self.available} of {self.sku} left, "
                f"requested {self.requested}")


try:
    raise OutOfStockError("MUG-01", 5, 2)
except OutOfStockError as e:
    print(e)
    print(repr(e))

Output:

only 2 of MUG-01 left, requested 5
OutOfStockError(sku='MUG-01', requested=5, available=2)

The generated __init__ doesn't call super().__init__(), but that's fine here: BaseException.__new__ already stores the constructor arguments in args, so pickling still works. Define __str__ yourself, as above, because the default would just show the args tuple. Avoid frozen=True on exception dataclasses: some code assigns attributes like __traceback__ on exceptions as they pass through (the @contextmanager machinery in contextlib does), and a frozen dataclass turns that into a FrozenInstanceError that replaces your real error.

Default Messages and Error Codes

In web apps and APIs, it's common to want every error to map to a response with a stable code. Class attributes handle this neatly:

# app/errors.py
class AppError(Exception):
    """Base class with a default message and an error code."""

    default_message = "an application error occurred"
    code = "app_error"

    def __init__(self, message: str | None = None) -> None:
        super().__init__(message or self.default_message)


class NotFoundError(AppError):
    default_message = "resource not found"
    code = "not_found"


class PermissionDeniedError(AppError):
    default_message = "you don't have access to this resource"
    code = "forbidden"


STATUS = {NotFoundError: 404, PermissionDeniedError: 403}


def to_response(err: AppError) -> tuple[int, dict[str, str]]:
    status = STATUS.get(type(err), 500)
    return status, {"error": err.code, "message": str(err)}


print(to_response(NotFoundError()))
print(to_response(NotFoundError("order 42 does not exist")))
print(to_response(AppError()))

Output:

(404, {'error': 'not_found', 'message': 'resource not found'})
(404, {'error': 'not_found', 'message': 'order 42 does not exist'})
(500, {'error': 'app_error', 'message': 'an application error occurred'})

Subclasses only override two class attributes. A single error handler in your web framework (Flask's errorhandler, FastAPI's exception_handler, or Django middleware) can then turn any AppError into a consistent JSON response. The code that raises errors doesn't need to know anything about HTTP.

Translating Errors at Module Boundaries

A good rule for libraries and layered applications: low-level exceptions shouldn't leak through your public API. Callers of load_config() shouldn't have to know you used json and pathlib internally. Catch those errors at the boundary and raise your own, chained with from so the original is still available:

# config.py
import json
from pathlib import Path


class ConfigError(Exception):
    """Raised when the application config can't be loaded."""


def load_config(path: Path) -> dict:
    try:
        return json.loads(path.read_text(encoding="utf-8"))
    except FileNotFoundError as exc:
        raise ConfigError(f"config file not found: {path}") from exc
    except json.JSONDecodeError as exc:
        raise ConfigError(f"invalid JSON in {path} at line {exc.lineno}") from exc

Calling it with a broken file:

p = Path("bad.json")
p.write_text('{"debug": true,,}')

try:
    load_config(p)
except ConfigError as e:
    print(e)
    print(type(e.__cause__).__name__)

Output:

invalid JSON in bad.json at line 1
JSONDecodeError

The caller handles one exception type with a clear message, and __cause__ still points at the original JSONDecodeError for anyone debugging. The full traceback shows both, joined by "The above exception was the direct cause of the following exception."

Only translate errors you expect. Don't wrap everything in except Exception: raise ConfigError(...), since that turns genuine bugs into misleading config errors.

Mixing In Built-in Exception Types

Sometimes you want a custom exception that also behaves like a built-in, so existing code that catches the built-in keeps working. Multiple inheritance handles that:

class ConfigError(Exception):
    pass


class MissingSettingError(ConfigError, KeyError):
    pass


try:
    raise MissingSettingError("database_url")
except KeyError as e:
    print("caught as KeyError:", repr(e), str(e))

Output:

caught as KeyError: MissingSettingError('database_url') 'database_url'

This is a nice way to evolve an API: callers that used to catch KeyError still work, and new callers can catch the more specific MissingSettingError or the broader ConfigError. Notice that str(e) includes quotes; that's KeyError's own __str__ showing through. Mixins inherit behavior, not just the name.

Adding Context Without a New Class

You don't always need a new class to make an error clearer. Since Python 3.11, any exception has add_note(), which attaches extra lines that appear in the traceback:

class PaymentError(Exception):
    pass


try:
    try:
        raise PaymentError("declined")
    except PaymentError as e:
        e.add_note("order_id=1234")
        raise
except PaymentError as e:
    print(e.__notes__)  # ['order_id=1234']

Use notes for context that varies per occurrence (an order ID, a file name, a retry count). Use a custom class when the kind of failure is different and callers will branch on it.

Using Custom Exceptions for Control Decisions

Custom exceptions make retry logic and similar decisions explicit. Instead of inspecting error messages, a retry helper can catch a specific base class:

# retry.py
from collections.abc import Callable


class RetryableError(Exception):
    """An error that may succeed if the operation is attempted again."""


def with_retries[T](func: Callable[[], T], attempts: int = 3) -> T:
    for attempt in range(1, attempts + 1):
        try:
            return func()
        except RetryableError as e:
            print(f"attempt {attempt} failed: {e}")
            if attempt == attempts:
                raise
    raise AssertionError("unreachable")


calls = 0


def flaky() -> str:
    global calls
    calls += 1
    if calls < 3:
        raise RetryableError("temporary glitch")
    return "ok"


print(with_retries(flaky))

Output:

attempt 1 failed: temporary glitch
attempt 2 failed: temporary glitch
ok

Any exception in your codebase can opt into retries by inheriting from RetryableError, for example class GatewayTimeoutError(PaymentError, RetryableError). The retry helper never needs to change. (The [T] syntax is Python 3.12+ generic function syntax; Generics, Protocols, and TypedDict explains it.)

Common Mistakes

  • Inheriting from BaseException. Your errors slip past except Exception: handlers. Always inherit from Exception or a subclass of it.
  • One class per raise site. If no caller will ever catch UserEmailTooLongError separately from ValidationError, it's noise. Add classes when there's a handling decision behind them.
  • Formatting the message in __init__ and dropping the raw data. Callers end up parsing strings. Store fields as attributes.
  • Not passing arguments to super().__init__(). You can get broken pickling, an empty str(), or a confusing repr. Pass the constructor arguments through and customize __str__.
  • Catching your base class too broadly inside the library itself. except PaymentError: pass deep in your own code hides real problems from your callers.
  • Leaking implementation exceptions. If you switch from json to tomllib, callers catching json.JSONDecodeError break. Translate at the boundary.

Conclusion

Custom exceptions are small classes with an outsized effect on how readable your error handling is. Start with a single base class per package that inherits from Exception, add subclasses only where callers will respond differently, and store structured data as attributes instead of burying it in a message string.

For exceptions with extra fields, pass all constructor arguments to super().__init__() and build the message in __str__, or use a dataclass with a custom __str__. Translate low-level errors at your module's boundary with raise ... from, and use add_note() when you just need more context. When several of these errors can happen at once, for example across concurrent tasks, Python has a dedicated tool for that: Exception Groups and except* in Python.

Tags :
Share :

Related Posts

Abstract Base Classes in Python with the abc Module

Abstract Base Classes in Python with the abc Module

Python leans on duck typing: if an object has the method you need, you call it and move on. That works well until you have a family of classes that a

Continue Reading
*args and **kwargs in Python: Flexible Function Signatures

*args and **kwargs in Python: Flexible Function Signatures

You've seen def wrapper(*args, **kwargs): in decorators, and probably super().__init__(**kwargs) in class hierarchies. These two parameters let a

Continue Reading
Asyncio in Python: A Beginner's Guide to Asynchronous Programming

Asyncio in Python: A Beginner's Guide to Asynchronous Programming

A lot of programs spend most of their time waiting. A web scraper waits for pages to download, an API server waits for the database, a chat bot waits

Continue Reading