Previous | Tutorial index | Next
Python provides a rich set of built-in exceptions that cover a wide range of common error conditions: ValueError, TypeError, FileNotFoundError, etc. However, when you are building a complex application—especially one with specific business logic—these generic exceptions often fail to capture the exact nature of the problem. For example, if a customer tries to withdraw more money than they have in their account, saying "ValueError" doesn't really tell the story. It would be much clearer to raise an InsufficientFundsError or OverdraftError.
User-defined exceptions allow you to create your own exception classes that represent errors specific to your application domain. This makes your code:
In this tutorial, we will learn how to define and use custom exceptions effectively.
Before diving into the content, familiarise yourself with these key terms:
| Term | Definition |
|---|---|
| Custom exception | A user-defined exception class that inherits from Exception or one of its subclasses. |
| Exception hierarchy | A set of exception classes derived from a common base, allowing broad or narrow catching. |
__init__ override |
Used to add custom attributes and set the error message. |
super().__init__(message) |
Calls the parent constructor to store the error message. |
| Exception chaining | Using raise ... from to link a custom exception to an original cause. |
| Base exception | A top-level custom exception for a module, from which all other module-specific exceptions derive. |
Imagine a building with a generic alarm system. If a fire breaks out, the alarm rings—but it also rings for a break-in, a gas leak, or a water pipe burst. You wouldn't know what the emergency is. Now imagine a building with specialized alarms: a fire alarm, a burglar alarm, a gas alarm, etc. When the fire alarm sounds, everyone immediately knows it's a fire and can respond appropriately.
Similarly, built‑in exceptions are like generic alarms—they tell you "something is wrong," but not exactly what. Custom exceptions are like specialized alarms—they give precise information about the nature of the error, enabling more targeted handling.
You should consider creating a custom exception when:
InsufficientFundsError in a banking app, InvalidOrderError in an e‑commerce system).DatabaseError, ConnectionError, QueryError all inherit from a base DatabaseError).Rule of thumb: If you find yourself using a generic exception like RuntimeError or ValueError with a long message that describes a very specific problem, consider defining a custom exception class instead.
The simplest way to define a custom exception is to create a class that inherits from Exception (or one of its subclasses). You don't even need to add any methods—just the class definition is enough.
class InsufficientFundsError(Exception):
"""Raised when an account has insufficient funds for a withdrawal."""
pass
# Usage
raise InsufficientFundsError("Cannot withdraw: balance too low")
This works, but it doesn't add any custom behavior or attributes. It just gives a new name to an error.
To make your exception more informative, you can override the __init__ method to accept additional parameters and store them as attributes. You should also call the parent class's __init__ to set the error message properly.
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
message = f"Cannot withdraw {amount} from balance {balance}"
super().__init__(message)
# Raising
raise InsufficientFundsError(100, 150)
Now when the exception is caught, the caller can access e.balance and e.amount for fine‑grained handling.
Custom exception classes can have methods just like any other class. For example, you might add a method to format the error for display, or a method to check if the error is recoverable.
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Cannot withdraw {amount} from balance {balance}")
def shortfall(self):
"""Return the amount that is short."""
return self.amount - self.balance
def is_critical(self):
"""Return True if the shortfall is more than 1000."""
return (self.amount - self.balance) > 1000
Sometimes it makes sense to inherit from a more specific built‑in exception, such as ValueError or RuntimeError. This allows your custom exception to be caught by handlers that expect the parent type, while still being more specific.
class NegativeAmountError(ValueError):
"""Raised when a negative value is provided for an amount."""
def __init__(self, amount):
self.amount = amount
super().__init__(f"Amount cannot be negative: {amount}")
# Now it can be caught as ValueError as well.
For larger applications, it's common to define a base exception for your module or package, and then derive more specific exceptions from it. This allows callers to catch the base exception to handle any error from your module.
# Base exception for a banking module
class BankingError(Exception):
"""Base exception for all banking errors."""
pass
# Specific exceptions
class InsufficientFundsError(BankingError):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Insufficient funds: balance={balance}, requested={amount}")
class InvalidAccountError(BankingError):
def __init__(self, account_id):
self.account_id = account_id
super().__init__(f"Invalid account ID: {account_id}")
class TransferError(BankingError):
def __init__(self, message, from_account, to_account):
self.from_account = from_account
self.to_account = to_account
super().__init__(f"Transfer failed: {message}")
Now, in the calling code, you can catch BankingError to handle any banking-related exception, or catch specific ones for fine‑grained handling.
Raising and catching custom exceptions works exactly like built‑in exceptions.
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount < 0:
raise NegativeAmountError(amount) # custom exception
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
return self.balance
try:
account = BankAccount(100)
account.withdraw(150)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"You need ${e.shortfall()} more.")
except NegativeAmountError as e:
print(f"Error: {e}")
ConnectionError, ValidationError) to make it clear they are exceptions.Exception (or a more specific built‑in like ValueError) unless you have a good reason not to.__init__ to set the message and attributes; always call super().__init__(...).Let's build a set of custom exceptions for an order processing system.
class OrderError(Exception):
"""Base exception for order-related errors."""
pass
class InvalidOrderItemError(OrderError):
def __init__(self, item_id, reason):
self.item_id = item_id
self.reason = reason
super().__init__(f"Invalid order item {item_id}: {reason}")
class OutOfStockError(OrderError):
def __init__(self, item_id, requested_qty, available_qty):
self.item_id = item_id
self.requested_qty = requested_qty
self.available_qty = available_qty
super().__init__(f"Item {item_id}: requested {requested_qty}, only {available_qty} in stock")
class PaymentDeclinedError(OrderError):
def __init__(self, transaction_id, reason):
self.transaction_id = transaction_id
self.reason = reason
super().__init__(f"Payment declined for transaction {transaction_id}: {reason}")
# Usage in order processing
def process_order(order):
# Validate items...
for item in order.items:
if not is_valid_item(item):
raise InvalidOrderItemError(item.id, "Item not found")
if item.qty > get_stock(item.id):
raise OutOfStockError(item.id, item.qty, get_stock(item.id))
# Attempt payment...
if not charge_card(order):
raise PaymentDeclinedError(order.transaction_id, "Card expired")
# ...
Catching them:
try:
process_order(my_order)
except InvalidOrderItemError as e:
print(f"Order rejected: {e.reason} for item {e.item_id}")
except OutOfStockError as e:
print(f"Order rejected: only {e.available_qty} of item {e.item_id} available")
except PaymentDeclinedError as e:
print(f"Payment failed: {e.reason}. Transaction ID: {e.transaction_id}")
except OrderError as e: # catch all order errors
print(f"Order error: {e}")
When you catch a low‑level exception and want to raise a custom exception that wraps it, you can use exception chaining (raise ... from) to preserve the original cause.
def load_config(file_path):
try:
with open(file_path, 'r') as f:
return json.load(f)
except FileNotFoundError as e:
raise ConfigFileNotFoundError(file_path) from e
except json.JSONDecodeError as e:
raise ConfigCorruptError(file_path) from e
Now, when ConfigFileNotFoundError is caught, you can access the original FileNotFoundError via e.__cause__.
| Pitfall | Solution |
|---|---|
Inheriting from BaseException instead of Exception |
Always inherit from Exception unless you are creating a system‑level exception (very rare). |
Forgetting to call super().__init__() |
Always call it to set the error message properly. |
| Not providing useful attributes | Store relevant data; it makes handling much easier. |
| Over‑engineering a huge hierarchy | Start with a base exception and add specific ones as needed; don't create dozens of exceptions unless justified. |
| Using custom exceptions for normal control flow | Exceptions are for exceptional conditions; do not use them to replace conditionals. |
| Not documenting the exceptions | Write clear docstrings and document what each exception means. |
| Aspect | Recommendation |
|---|---|
| Base class | Inherit from Exception (or ValueError, etc.). |
| Naming | Suffix with "Error" (e.g., MyAppError). |
| Message | Provide a clear, descriptive message in super().__init__(). |
| Attributes | Store relevant data for handling and debugging. |
| Hierarchy | Create a base exception for your module. |
| Chaining | Use raise ... from to preserve causes. |
| Documentation | Write docstrings and list raised exceptions. |
Test your understanding of the concepts covered in this tutorial. Answer each question, then click to reveal the correct answer.
Q1: Why would you create a custom exception instead of using a built‑in one?
if statements.Q2: Which class should your custom exception inherit from?
BaseExceptionExceptionobjectRuntimeError (only)Q3: True or False: You cannot add custom attributes to an exception class.
Q4: What is the purpose of calling super().__init__(message) inside your custom exception's __init__?
Q5: If you have a base exception MyAppError and several derived exceptions, what can you catch with except MyAppError:?
MyAppError.Q6: What is exception chaining?
except block.from.Q7: Which of the following is a good naming convention for custom exceptions?
CustomErrorCustom_ErrorInsufficientFundsErrorerror_insufficientQ8: True or False: You should use custom exceptions for normal program flow (e.g., breaking out of loops).
Q9: Given the following custom exception, which attribute stores the invalid value?
class ValidationError(Exception):
def __init__(self, value, message):
self.value = value
super().__init__(message)
self.messageself.valueself.invalidself.argsQ10: What is the benefit of having a base exception for your module?
except.Complete the exercises below to reinforce your understanding. Sample solutions are provided after each exercise.
Instructions: Define a custom exception NegativeNumberError that is raised when a negative number is passed to a function that expects a non‑negative number. The exception should store the invalid value. Then, write a function calculate_square_root(x) that raises this exception if x < 0 (and uses assert for debug, but raise for runtime). Test it with valid and invalid inputs.
class NegativeNumberError(Exception):
def __init__(self, value):
self.value = value
super().__init__(f"Negative number not allowed: {value}")
def calculate_square_root(x):
if x < 0:
raise NegativeNumberError(x)
return x ** 0.5
# Test
try:
print(calculate_square_root(9)) # 3.0
print(calculate_square_root(-4)) # Raises NegativeNumberError
except NegativeNumberError as e:
print(f"Error: {e}")
Instructions: Create an exception hierarchy for a simple library system. Define a base exception LibraryError. Then derive three specific exceptions:
BookNotFoundError (takes book ID)BookAlreadyBorrowedError (takes book title and borrower)MemberLimitExceededError (takes member ID, max limit)Each should have appropriate attributes and messages.
Then, write a small class Library with a method borrow_book(book_id, member_id) that raises these exceptions based on conditions. Finally, write a try...except that catches the base exception and prints the error.
class LibraryError(Exception):
pass
class BookNotFoundError(LibraryError):
def __init__(self, book_id):
self.book_id = book_id
super().__init__(f"Book with ID {book_id} not found")
class BookAlreadyBorrowedError(LibraryError):
def __init__(self, title, borrower):
self.title = title
self.borrower = borrower
super().__init__(f"Book '{title}' is already borrowed by {borrower}")
class MemberLimitExceededError(LibraryError):
def __init__(self, member_id, max_limit):
self.member_id = member_id
self.max_limit = max_limit
super().__init__(f"Member {member_id} exceeded borrowing limit of {max_limit}")
class Library:
def borrow_book(self, book_id, member_id):
# Simulation logic...
if book_id == 101:
raise BookNotFoundError(book_id)
if book_id == 102:
raise BookAlreadyBorrowedError("Python 101", "Alice")
if member_id == 1:
raise MemberLimitExceededError(member_id, 5)
print("Book borrowed")
# Test
lib = Library()
try:
lib.borrow_book(101, 2)
except LibraryError as e:
print(f"Library error: {e}")
Instructions: Write a function validate_email(email) that raises a custom exception InvalidEmailError if the email does not contain '@' or is empty. The exception should store the invalid email. In the main program, call this function with a list of test emails, catch the exception, and print a user‑friendly message.
class InvalidEmailError(Exception):
def __init__(self, email):
self.email = email
super().__init__(f"Invalid email: {email}")
def validate_email(email):
if not email or '@' not in email:
raise InvalidEmailError(email)
return True
emails = ["test@example.com", "invalid", "", "user@domain"]
for email in emails:
try:
validate_email(email)
print(f"{email} is valid")
except InvalidEmailError as e:
print(f"Invalid: {e.email}")
Instructions: Write a function read_config(file_path) that tries to open the file and parse JSON. If FileNotFoundError occurs, raise a custom ConfigNotFoundError (with the file path) from the original exception. If json.JSONDecodeError occurs, raise ConfigParseError (with file path) from the original. In the main program, catch the custom exceptions and print both the custom message and the original cause.
class ConfigNotFoundError(Exception):
def __init__(self, file_path):
self.file_path = file_path
super().__init__(f"Configuration file not found: {file_path}")
class ConfigParseError(Exception):
def __init__(self, file_path):
self.file_path = file_path
super().__init__(f"Invalid JSON in configuration file: {file_path}")
import json
def read_config(file_path):
try:
with open(file_path, 'r') as f:
return json.load(f)
except FileNotFoundError as e:
raise ConfigNotFoundError(file_path) from e
except json.JSONDecodeError as e:
raise ConfigParseError(file_path) from e
try:
config = read_config("missing.json")
except ConfigNotFoundError as e:
print(e)
if e.__cause__:
print(f"Caused by: {e.__cause__}")
except ConfigParseError as e:
print(e)
if e.__cause__:
print(f"Caused by: {e.__cause__}")
Instructions: Extend the InsufficientFundsError from the tutorial with a method shortfall() that returns the difference between the requested amount and the balance. Write a function process_withdrawal(account_balance, amount) that raises this exception when amount > balance. In the except block, print a message like "You need {shortfall()} more to complete this withdrawal."
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(f"Cannot withdraw {amount}, balance is {balance}")
def shortfall(self):
return self.amount - self.balance
def process_withdrawal(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
new_balance = process_withdrawal(100, 150)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"Shortfall: {e.shortfall()}")
1. You are building an inventory management system. List five different types of errors that could occur in this domain. For each, define a custom exception class with appropriate attributes, and explain when each would be raised. Then, create a base exception InventoryError and derive all of them from it.
Example exceptions for an inventory system:
InventoryError (base) – all inventory-related exceptions derive from this.
ItemNotFoundError – raised when an item is not found.
item_idraise ItemNotFoundError(item_id="SKU-1234")InsufficientStockError – raised when stock is insufficient for an order.
item_id, requested_qty, available_qtyraise InsufficientStockError("SKU-1234", 10, 3)InvalidSKUError – raised when a SKU (Stock Keeping Unit) is invalid.
sku, reasonraise InvalidSKUError("1234", "SKU must contain at least one letter")CategoryMismatchError – raised when an item is placed in the wrong category.
item_id, expected_category, actual_categoryraise CategoryMismatchError("SKU-1234", "Electronics", "Apparel")SupplierUnavailableError – raised when a supplier is not available.
supplier_idraise SupplierUnavailableError("SUP-5678")Having a base InventoryError allows catching all inventory‑related exceptions at once.
2. The following code uses a built‑in ValueError for multiple different error conditions. Rewrite it using custom exceptions to make the code clearer.
def process_order(order_data):
if not order_data.get('customer_id'):
raise ValueError("Customer ID missing")
if not order_data.get('items'):
raise ValueError("No items in order")
total = sum(order_data['items'])
if total > 10000:
raise ValueError("Order total exceeds credit limit")
# process...
print("Order processed")
class OrderError(Exception):
pass
class MissingCustomerError(OrderError):
def __init__(self):
super().__init__("Customer ID missing")
class NoItemsError(OrderError):
def __init__(self):
super().__init__("No items in order")
class CreditLimitExceededError(OrderError):
def __init__(self, total, limit):
self.total = total
self.limit = limit
super().__init__(f"Order total {total} exceeds credit limit {limit}")
def process_order(order_data):
if not order_data.get('customer_id'):
raise MissingCustomerError()
if not order_data.get('items'):
raise NoItemsError()
total = sum(order_data['items'])
if total > 10000:
raise CreditLimitExceededError(total, 10000)
print("Order processed")
3. Design a weather application that retrieves weather data from an API. Define a set of custom exceptions to handle:
For each exception, include appropriate attributes (e.g., city name, status code, etc.). Write a mock function get_weather(city, api_key) that raises these exceptions under certain conditions (you can simulate with if statements). Then write a main program that calls this function and handles each exception separately, printing helpful messages.
class WeatherError(Exception):
pass
class InvalidApiKeyError(WeatherError):
def __init__(self, api_key):
self.api_key = api_key
super().__init__(f"Invalid API key: {api_key}")
class CityNotFoundError(WeatherError):
def __init__(self, city):
self.city = city
super().__init__(f"City not found: {city}")
class NetworkTimeoutError(WeatherError):
def __init__(self, timeout_seconds):
self.timeout = timeout_seconds
super().__init__(f"Network timeout after {timeout_seconds}s")
class DataParseError(WeatherError):
def __init__(self, response):
self.response = response
super().__init__("Failed to parse weather data")
def get_weather(city, api_key):
# Simulate conditions
if api_key == "invalid":
raise InvalidApiKeyError(api_key)
if city == "Atlantis":
raise CityNotFoundError(city)
if city == "Timeout":
raise NetworkTimeoutError(10)
if city == "BadData":
raise DataParseError("garbage")
return {"temperature": 20}
try:
get_weather("London", "valid")
except InvalidApiKeyError as e:
print(f"API key issue: {e}")
except CityNotFoundError as e:
print(f"City issue: {e}")
except NetworkTimeoutError as e:
print(f"Timeout: {e}")
except DataParseError as e:
print(f"Parse error: {e}")
except WeatherError as e:
print(f"General weather error: {e}")
4. You are developing a file processing module. Design an exception hierarchy with:
FileProcessingError.FileNotFoundError (you can inherit from built‑in), FilePermissionError, FileCorruptError, FileFormatError.Explain the benefits of having this hierarchy, and show an example of how a caller could use it to handle errors at different granularities.
class FileProcessingError(Exception):
"""Base exception for all file processing errors."""
pass
class FileNotFoundError(FileProcessingError):
"""Raised when a file is not found."""
def __init__(self, file_path):
self.file_path = file_path
super().__init__(f"File not found: {file_path}")
class FilePermissionError(FileProcessingError):
"""Raised when permission is denied to access a file."""
def __init__(self, file_path):
self.file_path = file_path
super().__init__(f"Permission denied: {file_path}")
class FileCorruptError(FileProcessingError):
"""Raised when a file is corrupted or unreadable."""
def __init__(self, file_path, reason):
self.file_path = file_path
self.reason = reason
super().__init__(f"File corrupted: {file_path}, reason: {reason}")
class FileFormatError(FileProcessingError):
"""Raised when a file has an unexpected format."""
def __init__(self, file_path, expected_format):
self.file_path = file_path
self.expected_format = expected_format
super().__init__(f"Invalid format in {file_path}, expected {expected_format}")
Benefits:
Granular error handling: Callers can catch specific errors to handle different situations differently. For example, they might create a new file if it's not found, but ask the user to fix permissions if access is denied.
Broad error handling: Callers can catch FileProcessingError to handle any file‑related error with a single except block, which is useful for top‑level error reporting.
Self‑documenting code: The hierarchy makes it clear what kinds of errors can occur when working with files in this module.
Consistent API: Users of the module know they can rely on FileProcessingError for all file‑related errors, making the module easier to use.
Example of caller handling at different granularities:
def process_user_file(file_path):
try:
# Attempt to process the file
process_file(file_path)
except FileNotFoundError as e:
print(f"Creating default file: {e.file_path}")
create_default_file(e.file_path)
except FilePermissionError as e:
print(f"Cannot access {e.file_path}. Please check permissions.")
except FileCorruptError as e:
print(f"File {e.file_path} is corrupt: {e.reason}")
except FileFormatError as e:
print(f"File {e.file_path} has incorrect format. Expected {e.expected_format}.")
except FileProcessingError as e:
print(f"An unexpected file processing error occurred: {e}")
5. Discuss the trade‑offs between using built‑in exceptions versus defining custom exceptions. When is it better to use a built‑in (e.g., ValueError) and when is it better to create a custom one? Include considerations such as code readability, maintainability, API design, and the needs of the caller.
The decision between using built‑in exceptions and defining custom ones involves careful consideration of several factors, including code readability, maintainability, API design, and the needs of the caller.
When to use built‑in exceptions:
Built‑in exceptions are well‑suited for common, generic error conditions that are not specific to your application domain. For example:
TypeError – when a function receives an argument of the wrong type.ValueError – when a function receives an argument of the right type but an inappropriate value.KeyError – when a dictionary key is missing.These exceptions are familiar to all Python developers and require no additional documentation. They are ideal for library code that performs general‑purpose operations, as users already understand their semantics. Using them reduces the cognitive load on developers who are already familiar with Python's standard exception types.
When to create custom exceptions:
Custom exceptions become necessary when the error is specific to your application or library. For example:
InsufficientFundsError in a banking application.InvalidOrderError in an e‑commerce system.NetworkTimeoutError in a networking library.Custom exceptions are also essential when you need to attach extra data to the exception. For instance, an InsufficientFundsError might store the current balance and the requested amount, allowing callers to provide more helpful error messages or implement recovery logic.
Trade‑offs:
Readability: Custom exceptions make code more self‑documenting. raise InsufficientFundsError() is clearer than raise ValueError("Insufficient funds").
Maintainability: Custom exceptions allow you to change error handling behaviour without modifying the calling code. If you need to add new error types, you can derive them from your base exception without affecting existing handlers.
API design: When designing a library, custom exceptions provide a clean, consistent API. Users of your library can catch your base exception to handle all errors, or catch specific ones for fine‑grained control.
Overhead: Creating custom exceptions adds to the codebase size and requires documentation. However, for well‑designed systems, the benefits outweigh the costs.
Conclusion:
Use built‑in exceptions for generic, well‑understood error conditions. Create custom exceptions when the error is specific to your domain, when you need to attach additional data, or when you are designing a library or API that requires clear error signalling. The key is to strike a balance—avoid creating custom exceptions for every possible error, but don't hesitate to create them when they genuinely improve code clarity and maintainability.
This tutorial provides a comprehensive understanding of user‑defined exceptions, from simple definitions to complex hierarchies. The quizzes, exercises, and homework will help solidify these concepts. Happy coding!