Python Custom Exceptions
A custom exception is a new exception class you define — usually by subclassing Python’s built-in Exception — so your code can signal specific, meaningful error conditions instead of relying on generic built-ins like ValueError or RuntimeError. Custom exceptions make error handling self-documenting: catching InsufficientFundsError tells the next reader exactly what went wrong, and lets calling code react precisely to that situation without parsing error message strings. They are one of the most important tools for building libraries and applications that fail loudly, clearly, and in ways that are easy to handle correctly.
Overview: How Custom Exceptions Work
Every exception in Python is an instance of a class, and every exception class ultimately inherits from BaseException. The built-ins you already know — ValueError, KeyError, TypeError, FileNotFoundError — are just classes defined in the standard library that inherit from Exception, which itself inherits from BaseException. A custom exception is nothing more than a new class you write, almost always by subclassing Exception (not BaseException directly — that base is reserved for things like SystemExit and KeyboardInterrupt, which a plain except Exception is intentionally designed not to catch).
When you write class OrderError(Exception): pass, you get a fully functional exception type for free: it can be raised with raise OrderError(...), caught with except OrderError, it automatically carries a traceback when raised, it supports the standard exception-chaining machinery (raise ... from ...), and isinstance checks in except clauses walk its whole method resolution order — so catching a parent class also catches every subclass.
The real power of custom exceptions comes from two things: naming and hierarchy. Naming means callers can catch exactly the failure they know how to handle (except InsufficientFundsError) instead of a vague except ValueError that might also fire for unrelated bugs elsewhere in the same code path. Hierarchy means you can design a tree of exception types — a single base class for your whole package, with specific subclasses underneath it — so calling code can catch broadly (except MyAppError to handle “anything my library can go wrong”) or narrowly (except InvalidTokenError to handle one specific case), depending on what it needs.
Custom exceptions can also carry structured data. Instead of cramming every detail into a message string that calling code then has to parse back apart, you override __init__ to store real attributes (an invalid value, a field name, a status code) directly on the exception instance, so handlers can inspect e.field_name or e.status_code programmatically instead of scraping a string.
Syntax
class ExceptionName(BaseClass):
"""Optional docstring explaining when this is raised."""
def __init__(self, arguments): # optional: only for extra data
# store custom attributes here
super().__init__(message) # sets up args / str() correctly
raise ExceptionName(arguments) # raise it
raise ExceptionName(arguments) from cause # raise it, chained to a cause
| Part | Meaning |
|---|---|
class ExceptionName(BaseClass) |
Defines a new exception type. BaseClass is almost always Exception or one of your own exception classes. |
__init__ |
Optional. Override it only when the exception needs to store extra data beyond a plain message. |
super().__init__(message) |
Passes the message up to Exception.__init__, which stores it in self.args — this is what str(exception) uses. |
raise ExceptionName(...) |
Creates an instance and raises it, unwinding the stack until a matching except is found. |
from cause |
Explicitly links this exception to the one that caused it, so both appear in the traceback. |
Examples
Example 1: A basic custom exception
This is the simplest possible custom exception — a class with no extra behavior at all. It exists purely to give a specific, catchable name to a specific failure.
class InsufficientFundsError(Exception):
pass
def withdraw(balance: float, amount: float) -> float:
if amount > balance:
raise InsufficientFundsError(
f"Cannot withdraw {amount}: balance is only {balance}"
)
return balance - amount
try:
withdraw(100, 150)
except InsufficientFundsError as e:
print(f"Transaction failed: {e}")
Output:
Transaction failed: Cannot withdraw 150: balance is only 100
Because InsufficientFundsError inherits from Exception, everything works automatically: raise creates an instance and, since we didn’t override __init__, the single message argument flows straight into Exception.__init__. except InsufficientFundsError as e matches it precisely — a plain except ValueError would not catch this at all, which is exactly the point.
Example 2: Storing structured data on the exception
Most real exceptions need to carry more than a message. Here FieldTooShortError stores the field name and both lengths as real attributes, so calling code can act on the data programmatically instead of parsing a string. Notice the super().__init__(message) call at the end of __init__ — it’s what makes str(e) return the friendly message instead of a raw tuple (more on why in “Under the Hood” below).
class ValidationError(Exception):
"""Base class for validation errors in this module."""
class FieldTooShortError(ValidationError):
def __init__(self, field_name: str, min_length: int, actual_length: int):
self.field_name = field_name
self.min_length = min_length
self.actual_length = actual_length
message = (
f"'{field_name}' must be at least {min_length} characters "
f"(got {actual_length})"
)
super().__init__(message)
def validate_username(username: str) -> None:
if len(username) < 5:
raise FieldTooShortError("username", 5, len(username))
try:
validate_username("bob")
except FieldTooShortError as e:
print(f"Validation failed: {e}")
print(f"Field: {e.field_name}, needed: {e.min_length}, got: {e.actual_length}")
Output:
Validation failed: 'username' must be at least 5 characters (got 3)
Field: username, needed: 5, got: 3
validate_username builds a FieldTooShortError from three positional arguments, but __init__ turns them into one clean message before handing it to the base class. The except block can then read e.field_name, e.min_length, and e.actual_length directly — far more useful than re-parsing a string.
Example 3: An exception hierarchy with chaining
Real applications usually need more than one custom exception, organized as a hierarchy. Here ConfigError is the base class, with MissingKeyError and InvalidValueError as specific subclasses. load_port also demonstrates exception chaining with from exc, which preserves the original KeyError or ValueError as the new exception’s cause — invaluable for debugging.
class ConfigError(Exception):
pass
class MissingKeyError(ConfigError):
pass
class InvalidValueError(ConfigError):
pass
def load_port(config: dict) -> int:
try:
raw_port = config["port"]
except KeyError as exc:
raise MissingKeyError("Config is missing required key 'port'") from exc
try:
return int(raw_port)
except ValueError as exc:
raise InvalidValueError(f"'port' must be an integer, got {raw_port!r}") from exc
configs = [
{"port": "8080"},
{"host": "localhost"},
{"port": "not-a-number"},
]
for cfg in configs:
try:
port = load_port(cfg)
print(f"Loaded port: {port}")
except ConfigError as e:
print(f"Config error: {e}")
Output:
Loaded port: 8080
Config error: Config is missing required key 'port'
Config error: 'port' must be an integer, got 'not-a-number'
The for loop catches only ConfigError, yet it handles both subclasses — that’s the hierarchy at work. If a chained exception ever escaped uncaught, Python’s traceback would print both the original KeyError or ValueError and the new ConfigError, joined by the line “The above exception was the direct cause of the following exception.”
Under the Hood: args, __str__, and Exception Chaining
Two internal mechanics explain almost every surprising thing about custom exceptions.
self.args and the default __str__
Every exception object stores whatever was passed to its constructor in a tuple called self.args — and this happens inside BaseException.__new__, before your own __init__ even runs. That means self.args already reflects the raw positional arguments used when the exception was created, regardless of what your __init__ does with them afterward. The default Exception.__str__ then formats itself purely from self.args: an empty string if there are no args, the single value if there’s exactly one, or the tuple’s repr() if there’s more than one. This is why calling super().__init__(message) at the end of a custom __init__ matters: it overwrites self.args with just that one, nicely-formatted message, so str(exception) — and therefore print(exception) and any uncaught traceback — shows something readable instead of a raw tuple of the constructor’s original arguments.
Exception chaining: __cause__ and __context__
When you translate one exception into another inside an except block, Python links the two automatically. Write raise NewError("...") from original_exc and the new exception’s __cause__ attribute is set to original_exc; Python’s default traceback printer then shows both, joined by “The above exception was the direct cause of the following exception.” If you simply write raise NewError("...") without from, but you’re still inside an except block handling another exception, Python still records that other exception in __context__ (implicit chaining) and prints “During handling of the above exception, another exception occurred.” You can suppress this entirely with raise NewError("...") from None, which hides the original traceback — useful when the original exception is truly an internal detail the caller doesn’t need to see.
Common Mistakes
Mistake 1: Forgetting the final super().__init__(message)
It’s tempting to store a formatted message as self.message and assume that’s what gets printed. But str() on an exception doesn’t look at self.message at all — it looks at self.args, which is populated from the constructor’s raw arguments before your __init__ body runs.
class OutOfRangeError(Exception):
def __init__(self, name: str, minimum: float, maximum: float, value: float):
self.name = name
self.minimum = minimum
self.maximum = maximum
self.value = value
self.message = f"{name} must be between {minimum} and {maximum}, got {value}"
# BUG: never calls super().__init__(), so self.args keeps the raw
# constructor arguments instead of this friendly message
try:
raise OutOfRangeError("temperature", 0, 100, 150)
except OutOfRangeError as e:
print(f"Caught: {e}")
Output:
Caught: ('temperature', 0, 100, 150)
OutOfRangeError was constructed with four positional arguments — "temperature", 0, 100, 150 — and those were stored in self.args before __init__ ever ran. Because __init__ never called super().__init__(), self.message holds the friendly text, but str(e) still falls back to the default formatting of self.args, which is a four-item tuple — hence the ugly output.
class OutOfRangeError(Exception):
def __init__(self, name: str, minimum: float, maximum: float, value: float):
self.name = name
self.minimum = minimum
self.maximum = maximum
self.value = value
message = f"{name} must be between {minimum} and {maximum}, got {value}"
super().__init__(message)
try:
raise OutOfRangeError("temperature", 0, 100, 150)
except OutOfRangeError as e:
print(f"Caught: {e}")
Output:
Caught: temperature must be between 0 and 100, got 150
The only change is the final super().__init__(message) call, which overwrites self.args with a single-element tuple containing the formatted message. Now str(e) — and any uncaught traceback — shows exactly the text you intended.
Mistake 2: Catching Exception too broadly in a translation layer
A broad except Exception inside a function that translates errors is a common way to accidentally hide real bugs. It’s meant to catch one specific failure, but Exception covers almost everything, including bugs that have nothing to do with validation.
class ValidationError(Exception):
pass
def parse_age(raw):
try:
age = int(raw)
if age < 0:
raise ValueError("age cannot be negative")
return age
except Exception:
raise ValidationError(f"Invalid age: {raw!r}")
try:
parse_age(None)
except ValidationError as e:
print(e)
Output:
Invalid age: None
parse_age(None) fails inside int(raw) with a TypeError — not a validation problem at all, but a caller bug (passing None instead of a string). Because except Exception is so broad, it catches the TypeError too and relabels it as a misleading ValidationError, hiding the real problem from whoever is debugging this later.
class ValidationError(Exception):
pass
def parse_age(raw):
try:
age = int(raw)
except ValueError as exc:
raise ValidationError(f"Invalid age: {raw!r}") from exc
if age < 0:
raise ValidationError(f"Age cannot be negative: {age}")
return age
try:
parse_age(None)
except TypeError:
print("A real bug (TypeError) surfaced instead of being masked as ValidationError")
except ValidationError as e:
print(e)
Output:
A real bug (TypeError) surfaced instead of being masked as ValidationError
By catching only ValueError — the specific exception int() raises for a badly formatted string — the unrelated TypeError is left alone and propagates normally, surfacing as what it actually is instead of being disguised as bad input.
Best Practices
- Always inherit from
Exception(or one of its subclasses), never fromBaseExceptiondirectly, so your exceptions are caught by ordinaryexcept Exceptionhandlers and aren’t confused with control-flow exceptions likeSystemExit. - Give your package or module a single root exception (for example
class MyAppError(Exception)) and make every other custom exception inherit from it, so callers can catch everything your code can raise with oneexcept MyAppError. - Always call
super().__init__(message)with one well-formatted message string, sostr(exception)and default tracebacks stay readable. - Suffix exception class names with
Error(ValidationError, notValidationProblem) to follow the convention set by the built-ins. - Use
raise NewError(...) from original_excwhen translating one exception into another, so the original traceback isn’t lost. - Store structured data as real attributes (like
e.status_code), not only embedded in the message string, so calling code doesn’t need to parse text to react programmatically. - Catch the most specific exception type you can handle; reserve broad
except Exceptionblocks for true top-level error boundaries, not routine logic. - Document what a function raises, and under what conditions, in its docstring — the same way you’d document its return value.
- Don’t use exceptions for ordinary control flow; reserve them for genuinely exceptional conditions.
Practice Exercises
- Write a
NegativeDepositError(Exception)and adeposit(balance, amount)function that raises it whenamountis negative. Store the invalid amount on the exception ase.amount, and include it in the message. - Design a small exception hierarchy for a JSON-config loader: a base
ConfigLoadError, and two subclassesMissingFieldErrorandTypeMismatchError. Write aget_field(data, name, expected_type)function that raises the appropriate one when a key is missing or has the wrong type, usingraise ... from ...to preserve the originalKeyErrororTypeError. - Extend the corrected
parse_agefunction from this lesson with a newAgeTooLargeError(still aValidationErrorsubclass) raised when the parsed age is greater than 150. Confirm that a caller usingexcept ValidationErrorstill catches it without any changes to theexceptclause.
Summary
- A custom exception is just a class, almost always inheriting from
Exception, that plugs intoraise/exceptlike any built-in exception. - Custom exceptions make error handling self-documenting and let calling code catch precisely the failure it knows how to handle.
- Override
__init__to attach structured data, but always finish withsuper().__init__(message)soself.args— and thereforestr(exception)— reflects a clean message rather than the raw constructor arguments. - Build a hierarchy with one root exception per package so callers can catch broadly or narrowly as needed.
- Use
raise NewError(...) from original_excto chain exceptions and preserve the original traceback when translating errors. - Catch specific exception types, not bare
Exception, inside translation layers — broad catches hide real bugs instead of surfacing them.
