Skip to main content

Custom Exceptions in Python

Error Handling

Designing Custom Exceptions

Name your errors, add context, and keep your code understandable.

Why custom exceptions

except ValueError catches every ValueError — yours, the standard library's, and your dependencies'. A custom type lets callers catch exactly your failure mode and nothing else. It also turns tracebacks into documentation: CardDeclined: insufficient funds explains itself; a generic RuntimeError does not.

The minimal version

A class that inherits from Exception, with a docstring. That is the whole recipe:

class CardDeclined(Exception):
"""Raised when the payment provider declines a card."""

raise CardDeclined("insufficient funds")
# CardDeclined: insufficient funds

The message you pass at the raise site works exactly like a built-in's. On naming: most exception names end in Error (ConfigError, TimeoutError), mirroring the standard library; event-style names like CardDeclined are fine when they read more naturally.

A small hierarchy

Group related failures under a shared base class so callers can be as precise or as broad as they need:

class BillingError(Exception):
"""Base exception for billing failures."""

class CardDeclined(BillingError):
pass

At application scale, one root plus a child per subsystem covers most needs — catching the base catches every child:

class AppError(Exception):
"""Base class for every error this application raises."""

class ConfigError(AppError):
"""Configuration is missing or invalid."""

class NetworkError(AppError):
"""A remote call failed."""

try:
raise ConfigError("DATABASE_URL is not set")
except AppError as e: # matches ConfigError too
print(f"{type(e).__name__}: {e}")
# ConfigError: DATABASE_URL is not set

This is ordinary inheritanceexcept matches an exception class or any of its subclasses.

Adding metadata

Store structured context (IDs, reasons) as attributes, and always call super().__init__ so str(e), e.args, and pickling keep working:

class CardDeclined(BillingError):
def __init__(self, *, user_id: str, reason: str):
self.user_id = user_id
super().__init__(f"Card declined: {reason}")

try:
raise CardDeclined(user_id="u_42", reason="insufficient funds")
except CardDeclined as e:
print(e.user_id) # u_42
print(e) # Card declined: insufficient funds

Expose helpful attributes for logging and analytics — a handler can read e.user_id instead of parsing the message.

Catching custom exceptions

try:
process_payment(user_id)
except CardDeclined as exc:
logger.warning("Card declined for user %s: %s", exc.user_id, exc)
except BillingError:
logger.error("Billing failure")

Catch specific subclasses first, then broader base classes — Python runs the first matching handler, so a base class listed first would shadow the specific one (see try/except ordering).

Wrapping third-party exceptions

At a boundary — config loading, API clients, database access — translate low-level exceptions into your own so callers depend on your types, not your current dependencies:

import json
from pathlib import Path

def load_config(path):
try:
return json.loads(Path(path).read_text())
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 config file: {path}") from exc

from exc keeps the original traceback attached (exception chaining), so except ConfigError is all a caller ever writes, while the log still shows whether the root cause was a missing file (FileNotFoundError) or bad JSON. Swap JSON for TOML later and no caller changes.

Frequently Asked Questions

Should I inherit from Exception or BaseException?

Exception, always. BaseException is reserved for exceptions that should escape normal handling — KeyboardInterrupt and SystemExit live there so that "except Exception" cannot swallow Ctrl-C or shutdown. A custom error derived from BaseException would slip past every ordinary handler in your codebase, which is almost never what you want.

Do I need a whole hierarchy, or is one exception class enough?

Start with one class per distinct failure a caller might handle differently. Introduce a shared base class as soon as you have two or three related errors — it costs one line and lets callers choose between catching one failure mode or the whole family. Do not pre-build deep trees for errors nobody catches.

Where should custom exceptions live in my project?

A single dedicated module, conventionally exceptions.py (or errors.py), at the top of the package. Every other module imports from it, which prevents circular imports and gives users of your library one obvious place to discover what can go wrong.

# myapp/exceptions.py
class AppError(Exception):
"""Base for all myapp errors."""

class ConfigError(AppError):
"""Configuration is missing or invalid."""

Next up in your learning path