Python Custom Exceptions: When and How to Define Your Own Error Types
Python’s built-in exceptions — ValueError, KeyError, TypeError — cover common error categories. But when you are building a library, an API, or a domain-specific application, “value error” is rarely descriptive enough. A custom exception lets you name the problem precisely, attach relevant context, and give callers something specific to catch.
The Case for Custom Exceptions
Consider a function that validates a user account. Something can go wrong in several distinct ways: the account might not exist, it might be suspended, or the password might be wrong. Using ValueError for all three gives callers no way to distinguish them.
# Vague — callers can't distinguish the error typedef login(username, password): if not account_exists(username): raise ValueError("Account not found") if account_suspended(username): raise ValueError("Account suspended") if not check_password(username, password): raise ValueError("Wrong password")With custom exceptions, each failure mode becomes a distinct, catchable type:
class AuthError(Exception): """Base for all authentication errors."""
class AccountNotFoundError(AuthError): pass
class AccountSuspendedError(AuthError): pass
class WrongPasswordError(AuthError): pass
def login(username, password): if not account_exists(username): raise AccountNotFoundError(f"No account for: {username!r}") if account_suspended(username): raise AccountSuspendedError(f"Account {username!r} is suspended") if not check_password(username, password): raise WrongPasswordError("Incorrect password")
# Callers can now be precisetry: login("alice", "wrong_pass")except AccountNotFoundError: redirect_to_signup()except AccountSuspendedError as e: show_suspension_notice(str(e))except WrongPasswordError: increment_failed_attempt_counter()Basic Custom Exception
The simplest custom exception is a class that inherits from Exception with no body other than pass or a docstring:
class InsufficientFundsError(Exception): """Raised when a withdrawal exceeds the available balance."""
balance = 100withdrawal = 150
if withdrawal > balance: raise InsufficientFundsError( f"Cannot withdraw £{withdrawal} — balance is only £{balance}" )The string passed to raise becomes the exception message, accessible via str(e) or e.args[0].
Adding Context with __init__
For richer context, override __init__ to store structured data on the exception:
class ValidationError(Exception): def __init__(self, field, value, reason): self.field = field self.value = value self.reason = reason # Pass a formatted message to the parent super().__init__(f"Validation failed for '{field}': {reason} (got {value!r})")
try: age = -5 if age < 0: raise ValidationError("age", age, "must be a positive integer")except ValidationError as e: print(e) # Validation failed for 'age': must be a positive integer (got -5) print(e.field) # age print(e.value) # -5 print(e.reason) # must be a positive integerStoring structured attributes means callers can inspect the exception programmatically, not just print its message.
Building an Exception Hierarchy
Group related exceptions under a common base class. This lets callers catch either a specific exception or the whole category.
class StorageError(Exception): """Base class for all storage-related errors."""
class ConnectionError(StorageError): def __init__(self, host, port): super().__init__(f"Could not connect to {host}:{port}") self.host = host self.port = port
class WriteError(StorageError): def __init__(self, path, reason): super().__init__(f"Failed to write {path}: {reason}") self.path = path
class ReadError(StorageError): def __init__(self, path): super().__init__(f"Failed to read {path}") self.path = path
# Catch a specific subtypetry: save_to_disk("/data/record.json")except WriteError as e: print(f"Write failed: {e.path}")
# Catch the whole categorytry: connect_to_cache() load_from_cache()except StorageError as e: print(f"Storage subsystem failed: {e}") use_fallback()Exception Chaining with raise from
When you catch one exception and raise another, use raise NewException(...) from original to preserve the original traceback. This is called exception chaining.
import json
class ConfigError(Exception): pass
def load_config(path): try: with open(path) as f: return json.load(f) except FileNotFoundError as e: raise ConfigError(f"Config file missing: {path}") from e except json.JSONDecodeError as e: raise ConfigError(f"Config file has invalid JSON: {path}") from e
try: config = load_config("settings.json")except ConfigError as e: print(f"Configuration error: {e}") print(f"Original cause: {e.__cause__}") # the original exceptionUsing from e preserves the original exception as __cause__. Without from, the original appears as __context__ but the relationship is less explicit.
To explicitly suppress the original exception and raise a clean one:
raise ConfigError("Config missing") from None # __cause__ is NoneWhen Not to Define a Custom Exception
For one-off uses inside a single module, a built-in exception with a clear message often serves just as well. Not every error needs a class.
When the built-in type fits exactly. If you are raising “this argument is wrong”, ValueError is correct and widely understood.
When the hierarchy adds no value. A chain of single-subclass hierarchies where callers never catch the base class just adds indirection.
A reasonable guideline: define a custom exception when callers need to catch it specifically, when you need to attach structured data, or when you are building a library that others will use.
Common Mistakes
Inheriting from BaseException instead of Exception. BaseException is the parent of SystemExit, KeyboardInterrupt, and GeneratorExit, which should not be caught by normal application code. Always inherit from Exception unless you have a very specific reason not to.
Forgetting super().__init__(message). Without this, str(e) returns an empty string and the exception message is invisible.
Naming exceptions without the “Error” suffix. The convention is SomethingError. Names like Invalid or Failed are less clear.