Module 7.1

Exception Handling

Exceptions let you handle errors gracefully instead of crashing. When something goes wrong, Python raises an exception. With try/except blocks, you can catch these errors, respond appropriately, and keep your program running. Think of exceptions as a safety net that prevents your entire application from falling apart when unexpected situations occur.

45 min
Intermediate
Hands-on
What You'll Learn
  • Try/except/else/finally blocks
  • Catching specific exception types
  • Raising exceptions with raise
  • Creating custom exception classes
  • Exception hierarchy and chaining
Contents
01

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.

Key Concept

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
BaseException
Root
SystemExit
Program termination
Exception
Most Common
KeyboardInterrupt
Ctrl+C pressed
ValueError
Invalid value
TypeError
Wrong type
KeyError
Missing key
IndexError
Out of bounds
FileNotFoundError
File missing

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.

02

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
Interactive
try: Run risky code
Exception?
NO
else: Success code
YES
except: Handle error
finally: Always runs
Cleanup
Continue Execution

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.

Best Practice: Catch the most specific exception first. Python checks except blocks in order and executes the first match.

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
03

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.

04

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

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

1 Which block runs only if no exception occurred?
2 What exception does int("abc") raise?
3 How do you create a custom exception?
4 What does a bare "raise" statement do?
5 When does the finally block execute?
6 How do you catch multiple exception types with the same handler?
Answer all questions to check your score