What Are Exceptions?
An exception is an error that occurs during program execution. When Python encounters something it cannot handle, it raises an exception and stops the program unless the exception is caught. Exceptions are objects that contain information about what went wrong.
Errors vs Exceptions
Syntax errors happen before code runs and must be fixed. Exceptions happen during execution and can be caught and handled. Well-written code anticipates potential exceptions and handles them gracefully.
Why it matters: Without exception handling, a single error crashes your entire program. With it, you can recover, log the issue, and continue running.
Exception Hierarchy Tree
Common Built-in Exceptions
| Exception | Cause | Example |
|---|---|---|
ValueError |
Invalid value for operation | int("abc") |
TypeError |
Wrong type for operation | "2" + 2 |
KeyError |
Missing dictionary key | d["missing"] |
IndexError |
List index out of range | lst[100] |
ZeroDivisionError |
Division by zero | 10 / 0 |
FileNotFoundError |
File does not exist | open("x.txt") |
# Examples of common exceptions
int("hello") # ValueError: invalid literal
"text" + 5 # TypeError: can only concatenate str
[1, 2, 3][10] # IndexError: list index out of range
{"a": 1}["b"] # KeyError: 'b'
10 / 0 # ZeroDivisionError: division by zero
Each exception type tells you exactly what went wrong. Reading the exception name and message helps you quickly identify and fix problems.
Try/Except Blocks
The try/except statement lets you catch and handle exceptions. Code that might raise an exception goes in the try block. If an exception occurs, Python jumps to the except block instead of crashing.
Try/Except/Else/Finally Flow
Basic Try/Except
# Basic exception handling
try:
number = int(input("Enter a number: "))
result = 10 / number
print(f"Result: {result}")
except:
print("Something went wrong!")
# This catches ANY exception - not recommended
A bare except catches everything, including keyboard interrupts. Always specify the exception type you expect for better error handling.
Catching Specific Exceptions
# Catch specific exception types
try:
number = int(input("Enter a number: "))
result = 10 / number
print(f"Result: {result}")
except ValueError:
print("That's not a valid number!")
except ZeroDivisionError:
print("Cannot divide by zero!")
Catching specific exceptions lets you provide targeted error messages and handle different errors differently.
The Complete Try Statement
try:
file = open("data.txt", "r")
content = file.read()
except FileNotFoundError:
print("File not found!")
content = ""
else:
print("File read successfully!")
finally:
print("Cleanup complete")
# Close file if it was opened
Use else for code that should run only on success. Use finally for cleanup that must happen regardless of success or failure.
Practice: Try/Except Basics
Task: Write a function that converts a string to an integer. Return -1 if the conversion fails.
Show Solution
def safe_int(text):
try:
return int(text)
except ValueError:
return -1
print(safe_int("42")) # Output: 42
print(safe_int("hello")) # Output: -1
Task: Write a function safe_divide(a, b) that returns a/b or None if division fails.
Show Solution
def safe_divide(a, b):
try:
return a / b
except ZeroDivisionError:
return None
print(safe_divide(10, 2)) # Output: 5.0
print(safe_divide(10, 0)) # Output: None
Task: Write get_value(d, key) that returns the value or "Not found" if key is missing.
Show Solution
def get_value(d, key):
try:
return d[key]
except KeyError:
return "Not found"
data = {"name": "Alice", "age": 30}
print(get_value(data, "name")) # Output: Alice
print(get_value(data, "salary")) # Output: Not found
Task: Write parse_and_double(text) that parses an integer and doubles it. Use else for the doubling logic.
Show Solution
def parse_and_double(text):
try:
num = int(text)
except ValueError:
return "Invalid input"
else:
return num * 2
print(parse_and_double("5")) # Output: 10
print(parse_and_double("abc")) # Output: Invalid input
Task: Write process_item(lst, idx) that gets lst[idx], converts to int, and returns it squared. Handle IndexError and ValueError separately.
Show Solution
def process_item(lst, idx):
try:
item = lst[idx]
num = int(item)
return num ** 2
except IndexError:
return "Index out of range"
except ValueError:
return "Cannot convert to integer"
data = ["10", "abc", "5"]
print(process_item(data, 0)) # 100
print(process_item(data, 1)) # Cannot convert to integer
print(process_item(data, 9)) # Index out of range
Multiple Exceptions
You can catch multiple exception types in different ways: separate except blocks, a tuple of exceptions, or a parent exception class. Each approach has its use case depending on whether you need different handling per exception.
Tuple of Exceptions
# Catch multiple exceptions with same handler
try:
value = data[key]
result = int(value)
except (KeyError, ValueError, TypeError) as e:
print(f"Error: {type(e).__name__}: {e}")
result = 0
Use a tuple when multiple exceptions should be handled the same way. The as e captures the exception object for inspection.
Accessing Exception Details
# Get exception information
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"Exception type: {type(e).__name__}")
print(f"Exception message: {e}")
print(f"Exception args: {e.args}")
The exception object contains useful information for logging, debugging, or providing detailed error messages to users.
Raising Exceptions
Use the raise statement to signal that something went wrong. You can raise built-in exceptions or your own custom ones. Raising exceptions lets you enforce contracts and fail fast when preconditions are not met.
Basic Raise Syntax
def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age seems unrealistic")
return age
try:
set_age(-5)
except ValueError as e:
print(f"Invalid age: {e}")
Raising exceptions early (fail fast) prevents invalid data from propagating through your program and causing confusing errors later.
Re-raising Exceptions
def process_data(data):
try:
result = risky_operation(data)
except ValueError:
print("Logging error...") # Log it
raise # Re-raise the same exception
# Use raise without arguments to re-raise
Re-raising lets you perform actions (like logging) when an exception occurs while still propagating the error up the call stack.
Practice: Raising Exceptions
Task: Write validate_positive(n) that raises ValueError if n is not positive.
Show Solution
def validate_positive(n):
if n <= 0:
raise ValueError("Number must be positive")
return n
try:
validate_positive(-5)
except ValueError as e:
print(e) # Number must be positive
Task: Write validate_email(email) that raises ValueError if email lacks @ symbol.
Show Solution
def validate_email(email):
if "@" not in email:
raise ValueError("Invalid email: missing @")
return email
try:
validate_email("invalid.email")
except ValueError as e:
print(e) # Invalid email: missing @
Task: Write log_and_raise(func, *args) that calls func with args, logs any exception, then re-raises it.
Show Solution
def log_and_raise(func, *args):
try:
return func(*args)
except Exception as e:
print(f"Error in {func.__name__}: {e}")
raise
def divide(a, b):
return a / b
try:
log_and_raise(divide, 10, 0)
except ZeroDivisionError:
print("Caught re-raised exception")
Custom Exceptions
Create custom exception classes by inheriting from Exception. Custom exceptions make your code more readable and allow callers to catch specific error types related to your application's domain.
Basic Custom Exception
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds account balance."""
pass
class BankAccount:
def __init__(self, balance):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError("Not enough funds")
self.balance -= amount
Custom exceptions communicate intent clearly. InsufficientFundsError is more meaningful than a generic ValueError.
Custom Exception with Attributes
class ValidationError(Exception):
def __init__(self, field, message):
self.field = field
self.message = message
super().__init__(f"{field}: {message}")
def validate_user(name, age):
if not name:
raise ValidationError("name", "cannot be empty")
if age < 0:
raise ValidationError("age", "must be positive")
Adding attributes to exceptions provides structured error information that callers can use to display field-specific error messages.
Practice: Custom Exceptions
Task: Create a NegativeNumberError exception. Write sqrt_safe(n) that raises it for negative numbers.
Show Solution
class NegativeNumberError(Exception):
pass
def sqrt_safe(n):
if n < 0:
raise NegativeNumberError("Cannot sqrt negative")
return n ** 0.5
try:
sqrt_safe(-4)
except NegativeNumberError as e:
print(e) # Cannot sqrt negative
Task: Create RangeError with min_val, max_val, actual attributes. Write check_range(n, lo, hi) that uses it.
Show Solution
class RangeError(Exception):
def __init__(self, min_val, max_val, actual):
self.min_val = min_val
self.max_val = max_val
self.actual = actual
super().__init__(f"{actual} not in [{min_val}, {max_val}]")
def check_range(n, lo, hi):
if not lo <= n <= hi:
raise RangeError(lo, hi, n)
return n
Task: Create a PaymentError base class with subclasses CardDeclinedError and InsufficientFundsError. Write process_payment that raises the appropriate one.
Show Solution
class PaymentError(Exception):
pass
class CardDeclinedError(PaymentError):
pass
class InsufficientFundsError(PaymentError):
pass
def process_payment(amount, balance, card_valid):
if not card_valid:
raise CardDeclinedError("Card was declined")
if amount > balance:
raise InsufficientFundsError("Not enough balance")
return balance - amount
Key Takeaways
Try/Except Catches Errors
Wrap risky code in try blocks. Except blocks handle errors gracefully without crashing your program.
Catch Specific Exceptions
Always catch specific exception types. Avoid bare except clauses that catch everything including system exits.
Else and Finally
Use else for code that runs only on success. Use finally for cleanup that must always happen.
Raise to Signal Errors
Use raise to signal that preconditions are not met. Fail fast rather than letting invalid data propagate.
Custom Exceptions Add Clarity
Create custom exception classes for domain-specific errors. They make code more readable and errors more actionable.
Exception Hierarchy Matters
Exceptions form a hierarchy. Catching a parent class catches all its children. Order except blocks from specific to general.
Knowledge Check
Quick Quiz
Test what you've learned about Python exception handling