Previous | Tutorial index | Next

📚 Tutorial 6: User-Defined Exceptions

Learning Objectives

1. Introduction: Making Your Errors Speak Your Language

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.

2. Key Terms

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.

3. Real-World Analogy: Specialized Alarms

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.

4. When to Use Custom Exceptions

You should consider creating a custom exception when:

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.

5. Defining a Custom Exception Class

5.1 The Simplest Custom Exception

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.

5.2 Adding a Custom Constructor and Attributes

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.

5.3 Adding Methods to Exceptions

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

5.4 Inheriting from a More Specific Built‑in Exception

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.

6. Creating an Exception Hierarchy

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.

7. Using Custom Exceptions: Raising and Catching

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}")

8. Best Practices for Custom Exceptions

  1. Name your exceptions with a suffix "Error" (e.g., ConnectionError, ValidationError) to make it clear they are exceptions.
  2. Inherit from Exception (or a more specific built‑in like ValueError) unless you have a good reason not to.
  3. Provide a clear docstring explaining what the exception represents and when it is raised.
  4. Store useful attributes (e.g., the invalid value, the operation that failed, relevant state) to help debugging and handling.
  5. Override __init__ to set the message and attributes; always call super().__init__(...).
  6. Consider creating a base exception for your module/package, and derive all other exceptions from it.
  7. Avoid creating too many fine‑grained exceptions unless they serve a clear purpose. A balance must be struck between expressiveness and simplicity.
  8. Document all custom exceptions in your module's documentation.

9. Real‑World Example: E‑Commerce Order System

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}")

10. Exception Chaining with Custom Exceptions

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__.

11. Common Pitfalls and How to Avoid Them

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.

12. Summary

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.

13. Self-Assessment Quiz

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?

Answer(B) To provide more specific error names and additional context.

Q2: Which class should your custom exception inherit from?

Answer(B) `Exception` (or one of its subclasses).

Q3: True or False: You cannot add custom attributes to an exception class.

AnswerFalse. You can define custom attributes in `__init__`.

Q4: What is the purpose of calling super().__init__(message) inside your custom exception's __init__?

Answer(A) To initialize the parent class with the error message.

Q5: If you have a base exception MyAppError and several derived exceptions, what can you catch with except MyAppError:?

Answer(B) All exceptions derived from `MyAppError`.

Q6: What is exception chaining?

Answer(B) Linking a new exception to the original cause using `from`.

Q7: Which of the following is a good naming convention for custom exceptions?

Answer(C) `InsufficientFundsError` (PascalCase, ends with "Error").

Q8: True or False: You should use custom exceptions for normal program flow (e.g., breaking out of loops).

AnswerFalse. Exceptions are for exceptional conditions, not normal flow control.

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)
Answer(B) `self.value`

Q10: What is the benefit of having a base exception for your module?

Answer(A) It allows you to catch all module‑specific errors with one `except`.

14. Practical Exercises

Complete the exercises below to reinforce your understanding. Sample solutions are provided after each exercise.

Exercise 1: Define a Custom Exception

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.

Sample Answer
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}")

Exercise 2: Exception Hierarchy

Instructions: Create an exception hierarchy for a simple library system. Define a base exception LibraryError. Then derive three specific exceptions:

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.

Sample Answer
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}")

Exercise 3: Raising and Catching Custom Exceptions

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.

Sample Answer
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}")

Exercise 4: Exception Chaining

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.

Sample Answer
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__}")

Exercise 5: Adding Methods to Exceptions

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."

Sample Answer
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()}")

15. Homework Questions

Short Answer Questions

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.

Sample Answer

Example exceptions for an inventory system:

  1. InventoryError (base) – all inventory-related exceptions derive from this.

  2. ItemNotFoundError – raised when an item is not found.

    • Attributes: item_id
    • Example: raise ItemNotFoundError(item_id="SKU-1234")
  3. InsufficientStockError – raised when stock is insufficient for an order.

    • Attributes: item_id, requested_qty, available_qty
    • Example: raise InsufficientStockError("SKU-1234", 10, 3)
  4. InvalidSKUError – raised when a SKU (Stock Keeping Unit) is invalid.

    • Attributes: sku, reason
    • Example: raise InvalidSKUError("1234", "SKU must contain at least one letter")
  5. CategoryMismatchError – raised when an item is placed in the wrong category.

    • Attributes: item_id, expected_category, actual_category
    • Example: raise CategoryMismatchError("SKU-1234", "Electronics", "Apparel")
  6. SupplierUnavailableError – raised when a supplier is not available.

    • Attributes: supplier_id
    • Example: raise 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")
Sample Answer
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.

Sample Answer
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}")

Essay Question

4. You are developing a file processing module. Design an exception hierarchy with:

Explain the benefits of having this hierarchy, and show an example of how a caller could use it to handle errors at different granularities.

Sample Answer
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:

  1. 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.

  2. 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.

  3. Self‑documenting code: The hierarchy makes it clear what kinds of errors can occur when working with files in this module.

  4. 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}")

Research Question

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.

Sample Answer

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:

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:

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:

  1. Readability: Custom exceptions make code more self‑documenting. raise InsufficientFundsError() is clearer than raise ValueError("Insufficient funds").

  2. 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.

  3. 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.

  4. 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!

Previous | Tutorial index | Next