Module 9 - Exception Handling
This module covers exception handling in Python. You'll learn how to handle errors gracefully, create custom exceptions, and write robust error-resistant code.
1. Understanding Exceptions
1.1 What are Exceptions?
Exceptions are errors that occur during program execution. They disrupt the normal flow of code.
# Common exceptions
print(10 / 0) # ZeroDivisionError
print(numbers[10]) # IndexError
print(int('abc')) # ValueError
open('missing.txt') # FileNotFoundError
1.2 Exception Hierarchy
BaseException
├── SystemExit
├── KeyboardInterrupt
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ └── OverflowError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── ValueError
├── TypeError
├── FileNotFoundError
├── PermissionError
└── ... (many more)
Python has over 60 built-in exceptions. View all with print(dir(__builtins__)).
2. Try-Except Blocks
2.1 Basic Try-Except
# Without exception handling (program crashes)
number = int(input("Enter a number: "))
print(f"You entered: {number}")
# With exception handling
try:
number = int(input("Enter a number: "))
print(f"You entered: {number}")
except ValueError:
print("Invalid input! Please enter a number.")
# Program continues...
print("Program is still running")
2.2 Catching Multiple Exceptions
# Multiple except blocks
try:
numbers = [1, 2, 3]
index = int(input("Enter index: "))
print(numbers[index])
except ValueError:
print("Please enter a valid integer")
except IndexError:
print("Index out of range")
# Catch multiple exceptions in one block
try:
result = int(input("Enter number: ")) / int(input("Divide by: "))
print(result)
except (ValueError, ZeroDivisionError):
print("Invalid operation!")
# Catch multiple with different handling
try:
# Some code
pass
except ValueError as e:
print(f"Value error: {e}")
except IndexError as e:
print(f"Index error: {e}")
2.3 Catching All Exceptions
# Catch any exception
try:
# Risky code
result = risky_operation()
except Exception as e:
print(f"An error occurred: {e}")
print(f"Error type: {type(e).__name__}")
# Get detailed error info
import traceback
try:
result = 10 / 0
except Exception as e:
print("Error details:")
traceback.print_exc()
Avoid using bare except Exception unless you have a good reason. It can hide bugs and make debugging difficult.
3. Else and Finally Clauses
3.1 Else Clause
# Else: Executes if no exception occurred
try:
number = int(input("Enter a number: "))
except ValueError:
print("Invalid input!")
else:
print(f"You entered: {number}")
print("No errors occurred!")
# Practical example
try:
file = open('data.txt', 'r')
except FileNotFoundError:
print("File not found!")
else:
content = file.read()
print(content)
file.close()
3.2 Finally Clause
# Finally: Always executes (cleanup code)
try:
file = open('data.txt', 'r')
content = file.read()
except FileNotFoundError:
print("File not found!")
finally:
print("Cleanup code always runs")
# Resource cleanup
file = None
try:
file = open('data.txt', 'r')
content = file.read()
except FileNotFoundError:
print("File not found!")
finally:
if file:
file.close()
print("File closed")
3.3 Complete Try-Except Structure
try:
# Code that might raise exception
number = int(input("Enter number: "))
result = 100 / number
except ValueError:
# Handle ValueError
print("Invalid input!")
except ZeroDivisionError:
# Handle ZeroDivisionError
print("Cannot divide by zero!")
except Exception as e:
# Handle any other exception
print(f"Unexpected error: {e}")
else:
# Executes if no exception
print(f"Result: {result}")
finally:
# Always executes
print("Operation completed")
4. Raising Exceptions
4.1 Raise Statement
# Raise an exception
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero!")
return a / b
try:
result = divide(10, 0)
except ZeroDivisionError as e:
print(e)
# Raise without message
def validate_age(age):
if age < 0:
raise ValueError
# Raise with condition
def set_age(age):
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0 or age > 150:
raise ValueError("Age must be between 0 and 150")
return age
4.2 Re-raising Exceptions
# Re-raise caught exception
def process_data(data):
try:
result = risky_operation(data)
except Exception as e:
print(f"Error in process_data: {e}")
raise # Re-raise the same exception
# Re-raise as different exception
def load_config(filename):
try:
with open(filename, 'r') as file:
return json.load(file)
except FileNotFoundError:
raise ConfigError(f"Config file not found: {filename}")
except json.JSONDecodeError as e:
raise ConfigError(f"Invalid JSON in config: {e}")
5. Custom Exceptions
5.1 Creating Custom Exceptions
# Basic custom exception
class CustomError(Exception):
pass
# With custom message
class ValidationError(Exception):
def __init__(self, message):
self.message = message
super().__init__(self.message)
# Usage
def validate_email(email):
if '@' not in email:
raise ValidationError("Invalid email: missing @ symbol")
return True
try:
validate_email("user.example.com")
except ValidationError as e:
print(e)
5.2 Advanced Custom Exceptions
# Exception with additional attributes
class InsufficientFundsError(Exception):
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
self.shortage = amount - balance
message = f"Insufficient funds: need ${amount}, have ${balance}"
super().__init__(message)
# Usage
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
return self.balance
# Using custom exception
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"Short by: ${e.shortage}")
5.3 Exception Hierarchy
# Create exception hierarchy
class ApplicationError(Exception):
"""Base exception for application"""
pass
class DatabaseError(ApplicationError):
"""Database-related errors"""
pass
class ConnectionError(DatabaseError):
"""Database connection errors"""
pass
class QueryError(DatabaseError):
"""Database query errors"""
pass
class ValidationError(ApplicationError):
"""Validation errors"""
pass
# Usage
try:
# Database operation
raise ConnectionError("Failed to connect to database")
except DatabaseError as e:
# Catches ConnectionError and QueryError
print(f"Database error: {e}")
except ApplicationError as e:
# Catches all application errors
print(f"Application error: {e}")
6. Exception Best Practices
6.1 Be Specific
# ❌ Bad: Too broad
try:
# Code
pass
except:
print("Something went wrong")
# ✅ Good: Specific exceptions
try:
number = int(input("Enter number: "))
result = 100 / number
except ValueError:
print("Please enter a valid number")
except ZeroDivisionError:
print("Cannot divide by zero")
6.2 Don't Swallow Exceptions
# ❌ Bad: Silent failure
try:
risky_operation()
except Exception:
pass # Error is hidden!
# ✅ Good: Log the error
import logging
try:
risky_operation()
except Exception as e:
logging.error(f"Error in risky_operation: {e}")
# Maybe re-raise or handle appropriately
6.3 Use Finally for Cleanup
# ✅ Good: Cleanup in finally
def process_file(filename):
file = None
try:
file = open(filename, 'r')
return file.read()
except FileNotFoundError:
print("File not found")
return None
finally:
if file:
file.close()
# Better: Use context manager
def process_file(filename):
try:
with open(filename, 'r') as file:
return file.read()
except FileNotFoundError:
print("File not found")
return None
6.4 Fail Fast
# ✅ Good: Validate early
def process_user(user_id):
if not isinstance(user_id, int):
raise TypeError("user_id must be an integer")
if user_id < 0:
raise ValueError("user_id must be positive")
# Process user
return get_user(user_id)
# ❌ Bad: Late validation
def process_user(user_id):
# Lots of processing...
# Then validation at the end
pass
7. Common Patterns
7.1 Retry Logic
import time
def retry_operation(func, max_attempts=3, delay=1):
"""Retry operation on failure."""
for attempt in range(max_attempts):
try:
return func()
except Exception as e:
if attempt == max_attempts - 1:
raise # Re-raise on last attempt
print(f"Attempt {attempt + 1} failed: {e}")
time.sleep(delay)
# Usage
def unreliable_api_call():
# Might fail randomly
return requests.get('https://api.example.com/data')
result = retry_operation(unreliable_api_call)
7.2 Exception Chaining
# Chain exceptions (preserve context)
def load_user_data(user_id):
try:
data = fetch_from_database(user_id)
except DatabaseError as e:
raise UserDataError(f"Failed to load user {user_id}") from e
# Usage
try:
data = load_user_data(123)
except UserDataError as e:
print(f"Error: {e}")
print(f"Caused by: {e.__cause__}")
7.3 Context Manager for Exception Handling
from contextlib import contextmanager
@contextmanager
def error_handler(error_message):
"""Context manager for consistent error handling."""
try:
yield
except Exception as e:
print(f"{error_message}: {e}")
raise
# Usage
with error_handler("Failed to process data"):
process_data()
8. Assertions
8.1 Using Assertions
# Assert for debugging/development
def calculate_average(numbers):
assert len(numbers) > 0, "List cannot be empty"
return sum(numbers) / len(numbers)
# Assertions can be disabled with -O flag
# python -O script.py
# Don't use for validation in production
# ❌ Bad:
def withdraw(amount):
assert amount > 0, "Amount must be positive"
# ...
# ✅ Good:
def withdraw(amount):
if amount <= 0:
raise ValueError("Amount must be positive")
# ...
8.2 When to Use Assertions
# ✅ Good: Internal consistency checks
def set_age(self, age):
assert isinstance(age, int), "Age must be integer"
assert 0 <= age <= 150, "Age out of range"
self._age = age
# ✅ Good: Development/testing
def process_data(data):
assert isinstance(data, list), "Data must be a list"
# Process data
result = transform(data)
assert len(result) == len(data), "Result length mismatch"
return result
# ❌ Bad: User input validation
def login(username, password):
assert username, "Username required" # Use validation instead
assert password, "Password required" # Use validation instead
- Use assertions for development/debugging (internal checks)
- Use exceptions for runtime errors (user input, external failures)
- Assertions can be disabled; exceptions cannot
9. Logging Exceptions
9.1 Basic Exception Logging
import logging
logging.basicConfig(level=logging.ERROR)
try:
result = 10 / 0
except ZeroDivisionError:
logging.error("Division by zero attempted")
# Log with exception details
try:
result = 10 / 0
except ZeroDivisionError:
logging.exception("An error occurred") # Includes traceback
9.2 Advanced Logging
import logging
logger = logging.getLogger(__name__)
logger.setLevel(logging.DEBUG)
def process_transaction(amount):
try:
if amount < 0:
raise ValueError("Amount cannot be negative")
# Process transaction
logger.info(f"Processed transaction: ${amount}")
except ValueError as e:
logger.error(f"Validation error: {e}")
raise
except Exception as e:
logger.exception(f"Unexpected error processing ${amount}")
raise
10. Testing Exception Handling
10.1 Using pytest
import pytest
def divide(a, b):
if b == 0:
raise ZeroDivisionError("Cannot divide by zero")
return a / b
# Test that exception is raised
def test_divide_by_zero():
with pytest.raises(ZeroDivisionError):
divide(10, 0)
# Test exception message
def test_divide_by_zero_message():
with pytest.raises(ZeroDivisionError, match="Cannot divide by zero"):
divide(10, 0)
# Test exception attributes
def test_custom_exception():
with pytest.raises(InsufficientFundsError) as exc_info:
account = BankAccount(100)
account.withdraw(150)
assert exc_info.value.shortage == 50
10.2 Using unittest
import unittest
class TestDivision(unittest.TestCase):
def test_divide_by_zero(self):
with self.assertRaises(ZeroDivisionError):
divide(10, 0)
def test_divide_by_zero_message(self):
with self.assertRaisesRegex(ZeroDivisionError, "Cannot divide"):
divide(10, 0)
11. Summary
| Concept | Description | Example |
|---|---|---|
| try-except | Catch exceptions | try: ... except ValueError: |
| else | Runs if no exception | try: ... except: ... else: |
| finally | Always runs | try: ... finally: |
| raise | Raise exception | raise ValueError("Error") |
| Custom exceptions | Create exception classes | class CustomError(Exception): |
| Exception chaining | Preserve context | raise NewError() from e |
| assert | Development checks | assert x > 0 |
- Always catch specific exceptions
- Use custom exceptions for domain-specific errors
- Clean up resources in
finallyor use context managers - Don't swallow exceptions silently
- Log exceptions with context
- Use assertions for internal checks only
- Fail fast with early validation
12. What's Next?
In Module 10 - Object-Oriented Programming Basics, you'll learn:
- Creating classes and objects
- Attributes and methods
__init__constructor- Instance vs class attributes
- Basic OOP principles
13. Practice Exercises
Exercise 1: Input Validator
Create a function that validates user input with proper exception handling.
def get_positive_integer(prompt):
# Your code here
pass
Exercise 2: File Processor
Create a file processor with comprehensive error handling.
def process_file(filename):
# Handle file not found, permission errors, etc.
pass
Exercise 3: Custom Exception System
Create a hierarchy of custom exceptions for a banking system.
class BankingError(Exception):
pass
# Add more exception classes
Exercise 4: Retry Decorator
Create a decorator that retries a function on failure.
def retry(max_attempts=3):
# Your code here
pass
Exercise 5: Safe Calculator
Build a calculator with comprehensive exception handling.
class Calculator:
def divide(self, a, b):
# Your code here
pass
Try solving these exercises on your own first. Solutions will be provided in the practice section.