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:
- Callers can catch your error specifically. A library that only raises
ValueErrorforces callers to doexcept ValueError— which also catches every other randomValueErrorthat might happen. - Your stack traces are clearer.
ConfigError: missing portsays exactly what went wrong. - 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 ofOSError). Shadowing it confuses readers and breaksexcept 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.