Skip to main content

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)
Built-in Exceptions

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()
Catching All Exceptions

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
Assertions vs Exceptions
  • 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

ConceptDescriptionExample
try-exceptCatch exceptionstry: ... except ValueError:
elseRuns if no exceptiontry: ... except: ... else:
finallyAlways runstry: ... finally:
raiseRaise exceptionraise ValueError("Error")
Custom exceptionsCreate exception classesclass CustomError(Exception):
Exception chainingPreserve contextraise NewError() from e
assertDevelopment checksassert x > 0
Key Takeaways
  • Always catch specific exceptions
  • Use custom exceptions for domain-specific errors
  • Clean up resources in finally or 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
Solutions

Try solving these exercises on your own first. Solutions will be provided in the practice section.