Intermediate

Exception Handling in Python

Build robust applications by gracefully handling errors and unexpected situations

Errors are inevitable in programming. Users enter invalid data, files don't exist, network connections fail, and calculations produce unexpected results. The difference between a fragile program and a robust one is how it handles these situations.

Python's exception handling mechanism allows you to anticipate, catch, and handle errors gracefully, ensuring your programs don't crash unexpectedly and provide meaningful feedback to users.

📚 What You'll Learn

  • Understanding exceptions and error types
  • Using try-except blocks to catch errors
  • Multiple exception handling
  • The finally clause for cleanup code
  • Raising exceptions deliberately
  • Creating custom exceptions
  • Best practices for error handling

1. Understanding Exceptions

An exception is an event that disrupts the normal flow of a program. When Python encounters an error, it creates an exception object and stops the program unless you handle it.

Common Exception Types

# ZeroDivisionError
result = 10 / 0  # Error: division by zero

# TypeError
number = "100"
result = number + 50  # Error: can't add string and int

# ValueError
age = int("twenty")  # Error: invalid literal for int()

# KeyError
person = {"name": "Alice"}
age = person["age"]  # Error: key doesn't exist

# IndexError
numbers = [1, 2, 3]
item = numbers[10]  # Error: index out of range

# FileNotFoundError
with open("missing_file.txt") as f:  # Error: file doesn't exist
    content = f.read()

# AttributeError
text = "Hello"
text.append("!")  # Error: str has no append method
⚠️ Without Exception Handling: Any of these errors would crash your entire program. Users would see cryptic error messages and lose their work!

2. Basic Try-Except Blocks

The try-except block allows you to attempt code that might fail and handle the error gracefully if it does.

Basic Syntax

# Without exception handling (program crashes)
age = int(input("Enter your age: "))  # Crashes if user enters text

# With exception handling (program continues)
try:
    age = int(input("Enter your age: "))
    print(f"You are {age} years old")
except ValueError:
    print("Please enter a valid number!")
    age = 0  # Default value

print("Program continues running...")

Catching Multiple Exceptions

# Method 1: Separate except blocks
def divide_numbers(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Error: Cannot divide by zero!")
        return None
    except TypeError:
        print("Error: Please provide numbers only!")
        return None

print(divide_numbers(10, 2))    # Output: 5.0
print(divide_numbers(10, 0))    # Output: Error message
print(divide_numbers(10, "2"))  # Output: Error message

# Method 2: Single except for multiple types
try:
    # Some operation
    number = int(input("Enter a number: "))
    result = 100 / number
except (ValueError, ZeroDivisionError) as error:
    print(f"Invalid input: {error}")

# Method 3: Catch all exceptions (use sparingly)
try:
    # Risky operation
    result = some_risky_function()
except Exception as error:
    print(f"Something went wrong: {error}")
💡 Best Practice: Catch specific exceptions when possible. Catching all exceptions with a bare except: can hide bugs and make debugging harder.

3. The else and finally Clauses

The else Clause

The else block runs only if NO exception occurred in the try block. It's useful for code that should only run when everything succeeds.

def read_number():
    try:
        number = int(input("Enter a number: "))
    except ValueError:
        print("That's not a valid number!")
    else:
        # This runs only if no exception occurred
        print(f"You entered: {number}")
        print(f"Square: {number ** 2}")

# Example usage
read_number()

# Another example: File processing
try:
    file = open("data.txt", "r")
except FileNotFoundError:
    print("File not found!")
else:
    # Process file only if it opened successfully
    content = file.read()
    print(f"File has {len(content)} characters")
    file.close()

The finally Clause

The finally block ALWAYS runs, whether an exception occurred or not. It's perfect for cleanup tasks like closing files or releasing resources.

# Example: Always close the file
def process_file(filename):
    file = None
    try:
        file = open(filename, 'r')
        content = file.read()
        result = len(content)
        return result
    except FileNotFoundError:
        print(f"Error: {filename} not found")
        return 0
    except PermissionError:
        print(f"Error: No permission to read {filename}")
        return 0
    finally:
        # This ALWAYS runs - even if there's a return statement above!
        if file:
            file.close()
            print("File closed successfully")

# Example: Database connection
def query_database(query):
    connection = None
    try:
        connection = connect_to_database()
        result = connection.execute(query)
        return result
    except DatabaseError as e:
        print(f"Database error: {e}")
        return None
    finally:
        # Ensure connection is closed no matter what
        if connection:
            connection.close()
            print("Database connection closed")

Complete Flow

# All clauses together
def divide_with_logging(a, b):
    print("Starting division...")
    
    try:
        result = a / b
    except ZeroDivisionError:
        print("Error: Division by zero!")
        return None
    except TypeError:
        print("Error: Invalid types!")
        return None
    else:
        # Runs only if no exception
        print(f"Division successful: {result}")
        return result
    finally:
        # Always runs
        print("Division attempt completed\n")

# Test cases
print(divide_with_logging(10, 2))   # Success case
print(divide_with_logging(10, 0))   # ZeroDivisionError
print(divide_with_logging(10, "2")) # TypeError

4. Raising Exceptions

Sometimes YOU need to raise exceptions to signal that something is wrong. This is common in functions that validate input or enforce business rules.

Basic Raising

# Raise an exception manually
def validate_age(age):
    if age < 0:
        raise ValueError("Age cannot be negative!")
    if age > 150:
        raise ValueError("Age seems unrealistic!")
    return age

# Test
try:
    validate_age(-5)
except ValueError as error:
    print(f"Validation failed: {error}")

# Another example: Password validation
def set_password(password):
    if len(password) < 8:
        raise ValueError("Password must be at least 8 characters long")
    if not any(char.isdigit() for char in password):
        raise ValueError("Password must contain at least one digit")
    if not any(char.isupper() for char in password):
        raise ValueError("Password must contain at least one uppercase letter")
    
    print("Password set successfully!")

# Usage
try:
    set_password("weak")
except ValueError as error:
    print(f"Error: {error}")

Re-raising Exceptions

# Log error but still propagate it
def process_data(data):
    try:
        # Some processing
        result = complex_operation(data)
        return result
    except Exception as error:
        # Log the error
        print(f"Error logged: {error}")
        # Re-raise the same exception
        raise  # This passes the exception to the caller

# Caller must handle it
try:
    process_data(some_data)
except Exception:
    print("Could not process data")

5. Custom Exceptions

Create your own exception classes to represent specific error conditions in your application. This makes error handling more semantic and organized.

# Basic custom exception
class InsufficientFundsError(Exception):
    """Raised when account has insufficient funds for withdrawal."""
    pass

class BankAccount:
    def __init__(self, balance=0):
        self.balance = balance
    
    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"Cannot withdraw ${amount}. Balance: ${self.balance}"
            )
        self.balance -= amount
        return self.balance

# Usage
account = BankAccount(100)
try:
    account.withdraw(150)
except InsufficientFundsError as error:
    print(f"Transaction failed: {error}")

# More sophisticated custom exception
class ValidationError(Exception):
    """Base class for validation errors."""
    def __init__(self, field, message):
        self.field = field
        self.message = message
        super().__init__(f"{field}: {message}")

class InvalidEmailError(ValidationError):
    """Raised when email format is invalid."""
    def __init__(self, email):
        super().__init__("email", f"'{email}' is not a valid email address")

class InvalidAgeError(ValidationError):
    """Raised when age is out of valid range."""
    def __init__(self, age):
        super().__init__("age", f"{age} is not a valid age")

# Usage in a user registration system
class UserRegistration:
    def register(self, email, age):
        try:
            self.validate_email(email)
            self.validate_age(age)
            print(f"User registered: {email}")
        except ValidationError as error:
            print(f"Registration failed - {error}")
    
    def validate_email(self, email):
        if '@' not in email or '.' not in email.split('@')[-1]:
            raise InvalidEmailError(email)
    
    def validate_age(self, age):
        if not 0 <= age <= 120:
            raise InvalidAgeError(age)

# Test
registration = UserRegistration()
registration.register("user@example.com", 25)  # Success
registration.register("invalid-email", 25)     # Fails
registration.register("user@example.com", 150) # Fails

6. Real-World Examples

Example 1: Safe File Reader

def read_file_safely(filename):
    """
    Read a file with comprehensive error handling.
    """
    try:
        with open(filename, 'r') as file:
            content = file.read()
            return content
    except FileNotFoundError:
        print(f"Error: The file '{filename}' does not exist.")
        return None
    except PermissionError:
        print(f"Error: No permission to read '{filename}'.")
        return None
    except IsADirectoryError:
        print(f"Error: '{filename}' is a directory, not a file.")
        return None
    except Exception as error:
        print(f"Unexpected error: {error}")
        return None

# Usage
content = read_file_safely("data.txt")
if content:
    print(f"File read successfully: {len(content)} characters")

Example 2: API Request Handler

import requests  # Hypothetical example

class APIError(Exception):
    """Custom exception for API errors."""
    pass

def fetch_user_data(user_id):
    """
    Fetch user data from API with error handling.
    """
    max_retries = 3
    retry_count = 0
    
    while retry_count < max_retries:
        try:
            response = requests.get(f"https://api.example.com/users/{user_id}")
            
            # Raise exception for bad status codes
            if response.status_code != 200:
                raise APIError(f"API returned status code {response.status_code}")
            
            return response.json()
            
        except requests.ConnectionError:
            retry_count += 1
            print(f"Connection failed. Retry {retry_count}/{max_retries}...")
            if retry_count >= max_retries:
                raise APIError("Could not connect to API after multiple retries")
        
        except requests.Timeout:
            print("Request timed out")
            raise APIError("API request timed out")
        
        except APIError as error:
            print(f"API Error: {error}")
            return None

# Usage
try:
    user = fetch_user_data(123)
    if user:
        print(f"User: {user['name']}")
except APIError as error:
    print(f"Failed to fetch user: {error}")

Example 3: Input Validation Loop

def get_valid_integer(prompt, min_value=None, max_value=None):
    """
    Keep asking for input until a valid integer is entered.
    """
    while True:
        try:
            value = int(input(prompt))
            
            # Check range if specified
            if min_value is not None and value < min_value:
                raise ValueError(f"Value must be at least {min_value}")
            if max_value is not None and value > max_value:
                raise ValueError(f"Value must be at most {max_value}")
            
            return value
            
        except ValueError as error:
            print(f"Invalid input: {error}")
            print("Please try again.\n")

# Usage: Get age between 0 and 120
age = get_valid_integer("Enter your age: ", min_value=0, max_value=120)
print(f"You entered: {age}")

# Usage: Get menu choice
def show_menu():
    print("\n=== Menu ===")
    print("1. Option A")
    print("2. Option B")
    print("3. Option C")
    print("4. Exit")
    
    choice = get_valid_integer("Select option (1-4): ", min_value=1, max_value=4)
    return choice

choice = show_menu()
print(f"You selected option {choice}")

7. Best Practices

✅ Do's

  • Be Specific: Catch specific exceptions rather than using bare except:
  • Fail Fast: Validate input early and raise exceptions for invalid data
  • Clean Up: Use finally or context managers to ensure resources are released
  • Meaningful Messages: Provide clear error messages that help users understand what went wrong
  • Log Errors: In production, log exceptions for debugging later
  • Document Exceptions: Use docstrings to document what exceptions a function might raise

❌ Don'ts

  • Silent Failures: Don't catch exceptions without handling them or logging
  • Overly Broad: Avoid catching Exception unless necessary
  • Using Exceptions for Flow Control: Don't use exceptions for normal program logic
  • Empty Except Blocks: Never use except: pass - it hides all errors
  • Generic Messages: Avoid vague error messages like "Something went wrong"
# ❌ Bad: Silent failure
try:
    risky_operation()
except:
    pass  # Error is completely hidden!

# ✅ Good: Handle appropriately
try:
    risky_operation()
except SpecificError as error:
    log_error(error)
    notify_user("Operation failed")

# ❌ Bad: Too broad
try:
    result = process_data()
except Exception:
    print("Error!")  # What error? Where?

# ✅ Good: Specific handling
try:
    result = process_data()
except ValueError as error:
    print(f"Invalid data: {error}")
except KeyError as error:
    print(f"Missing key: {error}")
except Exception as error:
    print(f"Unexpected error: {error}")
    raise  # Re-raise unexpected errors

8. Practice Exercises

Exercise 1: Safe Calculator

Create a calculator that handles all possible errors gracefully.

Click to see solution
def safe_calculator():
    """Calculator with comprehensive error handling."""
    print("=== Safe Calculator ===")
    
    try:
        num1 = float(input("Enter first number: "))
        operator = input("Enter operator (+, -, *, /): ").strip()
        num2 = float(input("Enter second number: "))
        
        if operator == '+':
            result = num1 + num2
        elif operator == '-':
            result = num1 - num2
        elif operator == '*':
            result = num1 * num2
        elif operator == '/':
            if num2 == 0:
                raise ZeroDivisionError("Cannot divide by zero!")
            result = num1 / num2
        else:
            raise ValueError(f"Invalid operator: {operator}")
        
        print(f"\nResult: {num1} {operator} {num2} = {result}")
        
    except ValueError as error:
        print(f"Invalid input: {error}")
    except ZeroDivisionError as error:
        print(f"Math error: {error}")
    except KeyboardInterrupt:
        print("\nCalculation cancelled by user")
    except Exception as error:
        print(f"Unexpected error: {error}")

# Run
safe_calculator()

Exercise 2: File Backup System

Create a function that creates a backup of a file with proper error handling.

Click to see solution
import shutil
from datetime import datetime

def backup_file(source_file):
    """Create a timestamped backup of a file."""
    try:
        # Generate backup filename
        timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
        backup_file = f"{source_file}.backup_{timestamp}"
        
        # Copy file
        shutil.copy2(source_file, backup_file)
        print(f"✅ Backup created: {backup_file}")
        return backup_file
        
    except FileNotFoundError:
        print(f"❌ Error: Source file '{source_file}' not found")
        return None
    except PermissionError:
        print(f"❌ Error: No permission to read '{source_file}'")
        return None
    except Exception as error:
        print(f"❌ Backup failed: {error}")
        return None

# Test
backup_file("important_data.txt")

Key Takeaways

✅ What You've Learned

  • Try-Except: Catch and handle exceptions to prevent crashes
  • Exception Types: Python has many built-in exceptions for different errors
  • Finally Clause: Always runs cleanup code, even after return statements
  • Raising Exceptions: Signal errors in your code with raise
  • Custom Exceptions: Create specific exception classes for your application
  • Best Practices: Be specific, provide clear messages, and always clean up resources

🎯 What's Next?

With exception handling mastered, you're ready for:

  • File I/O: Read and write files with proper error handling
  • Working with APIs: Make network requests and handle connection errors
  • Debugging Techniques: Use Python's debugging tools effectively

📝 Knowledge Check

Test your understanding of exception handling!

Question 1: What block catches exceptions in Python?

A) catch
B) handle
C) except
D) error

Question 2: What block always executes, regardless of exceptions?

A) else
B) finally
C) always
D) complete

Question 3: Which is better practice?

A) Catch specific exceptions
B) Use bare except: for everything
C) Never handle exceptions
D) Ignore all errors

Question 4: How do you raise an exception manually?

A) throw
B) error
C) exception
D) raise

Question 5: What exception is raised when dividing by zero?

A) ValueError
B) TypeError
C) ZeroDivisionError
D) MathError