Sometimes the built-in exceptions aren’t specific enough. If your code talks about users, configs, or models, your errors should too. Custom exception classes let you do that.

Defining one

A custom exception is a class that inherits from Exception:

class ConfigError(Exception):
    """Raised when configuration is invalid or missing."""


class UserNotFoundError(Exception):
    """Raised when a user lookup fails."""

That’s the whole definition. You don’t need any methods. The Exception parent gives you everything — message handling, the traceback, the args attribute.

We’ll cover classes in detail in Section 12. For now, treat this as a recipe.

Using one

You raise and catch a custom exception the same way as a built-in:

def load_config(path: str) -> dict[str, str]:
    if not path:
        raise ConfigError("Config path is empty")
    # ... read the file ...
    return {"host": "localhost"}


try:
    config = load_config("")
except ConfigError as e:
    print(f"Config problem: {e}")

Why bother?

Three good reasons:

  1. Callers can catch your error specifically. A library that only raises ValueError forces callers to do except ValueError — which also catches every other random ValueError that might happen.
  2. Your stack traces are clearer. ConfigError: missing port says exactly what went wrong.
  3. You can add data. Custom classes can carry extra attributes that callers can inspect.

Adding data to an exception

A custom exception is a class, so you can give it more than a message:

class HttpError(Exception):
    def __init__(self, status: int, message: str) -> None:
        super().__init__(message)
        self.status = status


try:
    raise HttpError(404, "Page not found")
except HttpError as e:
    print(f"HTTP {e.status}: {e}")
HTTP 404: Page not found

The caller can examine e.status to decide how to react — a 404 might be ignored, a 500 might trigger a retry.

Building a hierarchy

For a library, group related exceptions under a common parent. Callers can then catch broadly or narrowly:

class DatabaseError(Exception):
    """Base for all database errors."""


class ConnectionError(DatabaseError):
    """Raised when we can't connect."""


class QueryError(DatabaseError):
    """Raised when a query fails."""


# caller can catch all database errors
try:
    do_database_work()
except DatabaseError as e:
    print(f"Database problem: {e}")

# or just one specific kind
try:
    do_database_work()
except ConnectionError:
    print("Network down, try later")

This is exactly how every Python library you’ve ever used (requests, sqlite3, NumPy) organises its exceptions.

When to use a built-in instead

If a built-in fits, use it. Don’t create MyValueError to wrap a plain ValueError — your callers expect the standard one.

Custom exceptions are for domain-specific failures: InvalidLicenseKey, RateLimitExceeded, ModelNotFound. Things that don’t have a direct built-in equivalent.

Naming convention

Always end the class name with Error (or Exception):

class QueryTimeoutError(Exception): ...    # good
class QueryTimeout(Exception): ...         # avoid — looks like a regular class

class NotFoundError(Exception): ...        # good
class NotFound(Exception): ...             # avoid

Don’t name your exception TimeoutError — that’s a built-in (a subclass of OSError). Shadowing it confuses readers and breaks except TimeoutError: in code that expects the standard one.

This is the universal Python convention. Stick to it and your code reads consistently.

A complete example

A small library for parsing greetings, with proper custom errors:

class GreetingError(Exception):
    """Base class for greeting-related errors."""


class EmptyGreetingError(GreetingError):
    """The input was empty."""


class UnknownLanguageError(GreetingError):
    def __init__(self, language: str) -> None:
        super().__init__(f"Unknown language: {language}")
        self.language = language


def greet(name: str, language: str = "en") -> str:
    if not name.strip():
        raise EmptyGreetingError("Name cannot be empty")

    greetings: dict[str, str] = {"en": "Hello", "ta": "Vanakkam", "ja": "Konnichiwa"}
    if language not in greetings:
        raise UnknownLanguageError(language)

    return f"{greetings[language]}, {name}!"


try:
    print(greet("Manikandan", "fr"))
except UnknownLanguageError as e:
    print(f"Try 'en', 'ta', or 'ja'. Got: {e.language}")
except GreetingError as e:
    print(f"Greeting problem: {e}")

The caller gets clear, specific errors — and can still fall back to the base class to catch any greeting failure.

What’s next

Final lesson of the section — the with statement, the cleanest way to handle resources that need cleanup.

Toggle theme (T)