Exception Handling in Python

Exception handling is a crucial aspect of writing robust Python code. Exceptions are events that occur during program execution that disrupt the normal flow. Instead of letting these events crash your program, Python allows you to handle them gracefully, making your code more resilient and user-friendly.

This guide covers the fundamentals of Python's exception handling system, including built-in exceptions, handling techniques, custom exceptions, and best practices.

Understanding Exceptions

What Are Exceptions?

Exceptions are objects that represent errors or unexpected conditions in your code. When an exception occurs, Python creates an exception object and stops normal execution, looking for exception handling code that can deal with it.

# Common exceptions in Python
# 1. SyntaxError - Invalid Python syntax
# if True print("Hello")  # SyntaxError: invalid syntax

# 2. TypeError - Operation/function applied to inappropriate type
# "2" + 2  # TypeError: can only concatenate str (not "int") to str

# 3. NameError - Name not defined
# print(undefined_variable)  # NameError: name 'undefined_variable' is not defined

# 4. IndexError - Index out of range
# lst = [1, 2, 3]
# print(lst[5])  # IndexError: list index out of range

# 5. KeyError - Key not found in dictionary
# d = {"a": 1}
# print(d["b"])  # KeyError: 'b'

# 6. ValueError - Value inappropriate for operation
# int("abc")  # ValueError: invalid literal for int() with base 10: 'abc'

# 7. AttributeError - Object has no attribute/method
# "hello".append("world")  # AttributeError: 'str' object has no attribute 'append'

# 8. ZeroDivisionError - Division by zero
# 10 / 0  # ZeroDivisionError: division by zero

# 9. FileNotFoundError - File doesn't exist
# open("nonexistent_file.txt")  # FileNotFoundError

# 10. ImportError - Module not found
# import nonexistent_module  # ImportError: No module named 'nonexistent_module'

Exception Hierarchy

Python exceptions are organized in a hierarchy. All exceptions derive from the BaseException class.

# Partial hierarchy of built-in exceptions
# BaseException
#  ├── SystemExit              # Raised by sys.exit()
#  ├── KeyboardInterrupt        # Raised when the user presses Ctrl+C
#  ├── GeneratorExit            # Raised when a generator is closed
#  └── Exception                # Base class for most exceptions
#       ├── StopIteration        # Raised when iterator has no more items
#       ├── ArithmeticError      # Base for arithmetic errors
#       │    ├── FloatingPointError
#       │    ├── OverflowError
#       │    └── ZeroDivisionError
#       ├── AttributeError
#       ├── ImportError
#       │    └── ModuleNotFoundError
#       ├── LookupError          # Base for lookup errors
#       │    ├── IndexError
#       │    └── KeyError
#       ├── NameError
#       ├── SyntaxError
#       ├── TypeError
#       ├── ValueError
#       └── ... many more

Basic Exception Handling

Try-Except Blocks

The basic mechanism for handling exceptions in Python is the try-except block.

# Basic try-except block
try:
    # Code that might raise an exception
    result = 10 / 0
except ZeroDivisionError:
    # Code to handle the exception
    print("Cannot divide by zero!")

# Handling multiple exceptions
try:
    number = int(input("Enter a number: "))
    result = 10 / number
except ValueError:
    print("That's not a valid number!")
except ZeroDivisionError:
    print("Cannot divide by zero!")

# Handling multiple exceptions with the same handler
try:
    # Some code that might raise different exceptions
    with open("file.txt") as f:
        content = f.read()
        number = int(content)
except (FileNotFoundError, ValueError) as e:
    print(f"An error occurred: {e}")

# Using the exception object
try:
    numbers = [1, 2, 3]
    print(numbers[5])
except IndexError as e:
    print(f"Error: {e}")  # Error: list index out of range

# Catching any exception (not recommended for production code)
try:
    # Risky operation
    result = eval(input("Enter an expression: "))
except Exception as e:
    print(f"An error occurred: {e}")
    # Perhaps log the error for debugging

# When to use general exception handling
def sample_function():
    try:
        # Try specific exceptions first
        # Complex operations...
        pass
    except ValueError:
        # Handle ValueError specifically
        pass
    except TypeError:
        # Handle TypeError specifically
        pass
    except Exception as e:
        # Handle any other exceptions
        # Log error details for debugging
        import logging
        logging.error(f"Unexpected error: {e}", exc_info=True)
        raise  # Re-raise the exception after logging

The Else and Finally Clauses

Python's exception handling includes optional else and finally clauses that provide additional control.

# Using else clause (runs if no exception is raised)
try:
    number = int(input("Enter a number: "))
    result = 100 / number
except ValueError:
    print("That's not a valid number!")
except ZeroDivisionError:
    print("Cannot divide by zero!")
else:
    # This runs only if no exceptions occurred
    print(f"The result is: {result}")

# Using finally clause (always runs, regardless of exceptions)
try:
    file = open("example.txt", "r")
    content = file.read()
except FileNotFoundError:
    print("File not found!")
finally:
    # This code always executes, even if an exception occurred
    # Good for cleanup operations
    if 'file' in locals() and not file.closed:
        file.close()
        print("File closed.")

# Complete try-except-else-finally example
try:
    # Attempt to perform an operation
    file = open("data.txt", "r")
    data = file.read()
    parsed_data = int(data)
except FileNotFoundError:
    print("The file was not found.")
    # Create a file with default data
    with open("data.txt", "w") as f:
        f.write("0")
    parsed_data = 0
except ValueError:
    print("The file doesn't contain a valid number.")
    parsed_data = 0
else:
    # This runs if no exceptions were raised
    print(f"Successfully read data: {parsed_data}")
finally:
    # This always runs for cleanup
    if 'file' in locals() and not file.closed:
        file.close()
        print("File closed.")

Advanced Exception Handling

Raising Exceptions

You can raise exceptions in your code using the raise statement.

# Raising exceptions
def divide(a, b):
    if b == 0:
        raise ZeroDivisionError("Cannot divide by zero")
    return a / b

# Raising with a message
def withdraw(account, amount):
    if amount > account.balance:
        raise ValueError(f"Insufficient funds: balance is {account.balance}, trying to withdraw {amount}")
    account.balance -= amount
    return account.balance

# Re-raising exceptions
try:
    # Some code that might raise an exception
    result = int("abc")
except ValueError:
    print("Invalid integer, handling and re-raising...")
    raise  # Re-raises the last exception

# Transforming exceptions
def get_user_data(user_id):
    try:
        # Try to retrieve user from database
        # This might raise various exceptions
        db = connect_to_database()
        user = db.query(f"SELECT * FROM users WHERE id = {user_id}")
        return user
    except Exception as e:
        # Transform into a more appropriate exception for the caller
        raise ValueError(f"Could not retrieve user {user_id}: {str(e)}") from e

Custom Exceptions

You can create your own exception classes for more specific error handling.

# Defining custom exceptions
class CustomError(Exception):
    """Base class for custom exceptions in this module."""
    pass

class ValueTooSmallError(CustomError):
    """Raised when the input value is too small."""
    def __init__(self, value, min_value, message="Value is too small"):
        self.value = value
        self.min_value = min_value
        self.message = f"{message}: {value} (minimum: {min_value})"
        super().__init__(self.message)

class ValueTooLargeError(CustomError):
    """Raised when the input value is too large."""
    def __init__(self, value, max_value, message="Value is too large"):
        self.value = value
        self.max_value = max_value
        self.message = f"{message}: {value} (maximum: {max_value})"
        super().__init__(self.message)

# Using custom exceptions
def validate_age(age):
    if age < 0:
        raise ValueTooSmallError(age, 0, "Age cannot be negative")
    if age > 150:
        raise ValueTooLargeError(age, 150, "Age is unrealistically high")
    return age

# Handling custom exceptions
try:
    user_age = int(input("Enter your age: "))
    validated_age = validate_age(user_age)
    print(f"Your age is {validated_age}")
except ValueTooSmallError as e:
    print(f"Error: {e}")
except ValueTooLargeError as e:
    print(f"Error: {e}")
except ValueError:
    print("Please enter a valid number.")

Context Managers and Exception Handling

Context managers (using the with statement) provide a way to handle exceptions and resource management.

# Context managers for exception handling
# Built-in examples
with open("file.txt", "r") as file:
    # File is automatically closed even if an exception occurs
    content = file.read()

# Custom context manager using a class
class DatabaseConnection:
    def __init__(self, connection_string):
        self.connection_string = connection_string
        self.connection = None
    
    def __enter__(self):
        # Set up resource (open connection)
        print(f"Connecting to database: {self.connection_string}")
        self.connection = {"connected": True, "cursor": "db_cursor"}
        return self.connection
    
    def __exit__(self, exc_type, exc_val, exc_tb):
        # Clean up resource (close connection)
        if self.connection:
            print("Closing database connection")
            self.connection["connected"] = False
            self.connection = None
        
        # Handle specific exceptions if desired
        if exc_type is not None:
            print(f"An exception occurred: {exc_val}")
            # Return True to suppress the exception
            # Return False (or None) to let the exception propagate
            return False

# Using the custom context manager
try:
    with DatabaseConnection("mysql://localhost/mydb") as conn:
        # Use the database connection
        print(f"Connected: {conn['connected']}")
        # If an exception occurs here, __exit__ will be called
        # raise ValueError("Database query failed")
except Exception as e:
    print(f"Error caught outside context manager: {e}")

# Context manager using contextlib
from contextlib import contextmanager

@contextmanager
def file_manager(filename, mode):
    try:
        # Setup - open the file
        file = open(filename, mode)
        # Yield the resource
        yield file
    finally:
        # Cleanup - close the file
        file.close()

# Using the contextlib-based manager
try:
    with file_manager("example.txt", "r") as file:
        content = file.read()
except FileNotFoundError:
    print("File not found!")

Exception Handling Best Practices

Following are some best practices for effective exception handling in Python.

# 1. Be specific with exception types
# Bad (too broad)
try:
    # Some code
    pass
except Exception:  # Catches everything - might hide bugs
    pass

# Good (specific)
try:
    # Some code
    pass
except ValueError:
    # Handle specifically
    pass
except ZeroDivisionError:
    # Handle specifically
    pass

# 2. Keep try blocks focused
# Bad (too broad)
try:
    file = open("data.txt")
    data = file.read()
    processed_data = process_data(data)
    result = calculate_result(processed_data)
    save_result(result)
except Exception as e:
    print(f"An error occurred: {e}")

# Good (focused)
try:
    file = open("data.txt")
except FileNotFoundError:
    print("File not found")
    return

try:
    data = file.read()
except IOError:
    print("Error reading file")
    file.close()
    return

# Process data, handle other specific exceptions...
# Remember to close the file

# 3. Use context managers for resource cleanup
# Bad
file = open("data.txt")
try:
    data = file.read()
except Exception:
    # Handle exception
    pass
# What if we forget to close the file?
file.close()

# Good
with open("data.txt") as file:
    data = file.read()
# File is automatically closed

# 4. Don't suppress exceptions without good reason
# Bad
try:
    result = risky_operation()
except Exception:
    # Silent failure is dangerous
    pass

# Good
try:
    result = risky_operation()
except Exception as e:
    # Log the error
    logger.error(f"Error in risky_operation: {e}")
    # Possibly re-raise or handle gracefully
    raise

# 5. Use exceptions for exceptional conditions, not flow control
# Bad (using exceptions for control flow)
def find_value(dictionary, key):
    try:
        return dictionary[key]
    except KeyError:
        return None

# Good (check conditions without exceptions)
def find_value(dictionary, key):
    if key in dictionary:
        return dictionary[key]
    return None

# 6. Include helpful information in custom exceptions
# Bad
if value < 0:
    raise ValueError("Invalid value")

# Good
if value < 0:
    raise ValueError(f"Value must be positive, got: {value}")

# 7. Use logging for debugging exceptions
import logging

try:
    # Some complex operation
    result = complex_operation()
except Exception as e:
    # Log with traceback for debugging
    logging.error("Error in complex_operation", exc_info=True)
    # Display user-friendly message
    print("An error occurred. Please check the logs.")
Back to Cheat Sheet