On this page

Error Handling

12 min read TextCh. 3 — Intermediate Python

Error Handling

In the real world, programs fail. Files do not exist, network connections drop, users enter invalid data, and APIs return unexpected responses. Robust programs anticipate these failures and handle them gracefully — logging the problem, informing the user, and recovering where possible, rather than crashing with an incomprehensible traceback.

Python's exception system is one of its most elegant features. Unlike error codes (where you must check every return value), Python uses a "try first, handle failures separately" approach that keeps the happy path clean and groups error handling in one place.

The Exception Hierarchy

Python exceptions are classes that form a hierarchy. All exceptions inherit from BaseException, and most user-facing exceptions inherit from Exception:

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── ArithmeticError
    │   ├── ZeroDivisionError
    │   └── OverflowError
    ├── LookupError
    │   ├── IndexError
    │   └── KeyError
    ├── TypeError
    ├── ValueError
    ├── NameError
    ├── AttributeError
    ├── FileNotFoundError
    ├── PermissionError
    ├── RuntimeError
    └── ... many more

The `try/except` Statement

The basic structure:

try:
    # Code that might raise an exception
    result = 10 / 0
except ZeroDivisionError:
    # Code that runs if ZeroDivisionError is raised
    print("Cannot divide by zero!")

Catching Multiple Exception Types

def safe_divide(a: float, b: float) -> float | None:
    try:
        return a / b
    except ZeroDivisionError:
        print("Error: Division by zero")
        return None
    except TypeError as e:
        print(f"Error: Invalid types — {e}")
        return None

print(safe_divide(10, 2))    # 5.0
print(safe_divide(10, 0))    # Error: Division by zero / None
print(safe_divide(10, "x"))  # Error: Invalid types / None

# Catching multiple types in a single except
def parse_number(text: str) -> int | None:
    try:
        return int(text)
    except (ValueError, TypeError):
        return None

print(parse_number("42"))     # 42
print(parse_number("hello"))  # None
print(parse_number(None))     # None

Accessing the Exception Object

import json

def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError as e:
        print(f"Config file not found: {e.filename}")
        return {}
    except json.JSONDecodeError as e:
        print(f"Invalid JSON at line {e.lineno}: {e.msg}")
        return {}
    except PermissionError:
        print("Permission denied when reading config")
        return {}

The as e clause binds the exception object to the name e, giving you access to its attributes and message.

`else` and `finally`

def read_integer(prompt: str) -> int | None:
    try:
        raw = input(prompt)
        value = int(raw)
    except ValueError:
        print(f"'{raw}' is not a valid integer.")
        return None
    else:
        # Runs ONLY if no exception was raised in try
        print(f"Successfully parsed: {value}")
        return value
    finally:
        # ALWAYS runs — even if an exception was raised and not caught
        print("read_integer() finished.")

The else clause is often overlooked but important: it lets you put code that should only run on success, without wrapping it in the try block where it might accidentally catch unintended exceptions. The finally clause is guaranteed to run — it is the right place for cleanup like closing files or releasing locks.

import time

def timed_operation(func, *args):
    start = time.perf_counter()
    try:
        result = func(*args)
        return result
    except Exception as e:
        print(f"Operation failed: {e}")
        return None
    finally:
        elapsed = time.perf_counter() - start
        print(f"Elapsed: {elapsed:.4f}s")   # always printed, success or failure

tip type: tip title: "Put minimal code in try blocks"

Only put the code that might raise the exception inside the try block — not everything that comes after it. The broader your try block, the harder it is to know which line actually raised the exception. Use else for code that should run only if the try succeeded.

Raising Exceptions

Use raise to raise an exception explicitly:

def set_age(age: int) -> None:
    if not isinstance(age, int):
        raise TypeError(f"Age must be an int, got {type(age).__name__!r}")
    if age < 0:
        raise ValueError(f"Age cannot be negative: {age}")
    if age > 150:
        raise ValueError(f"Age seems unrealistic: {age}")
    print(f"Age set to {age}")

set_age(25)   # Age set to 25

try:
    set_age(-5)
except ValueError as e:
    print(f"Caught: {e}")   # Caught: Age cannot be negative: -5

# Re-raising an exception
def process(data: str) -> str:
    try:
        return data.strip().upper()
    except AttributeError:
        raise   # re-raises the same exception with the original traceback

Exception Chaining

When one exception causes another, use raise ... from ... to link them:

class DatabaseError(Exception):
    pass

def query_database(sql: str) -> list:
    try:
        # Simulate a database error
        raise ConnectionError("Could not connect to PostgreSQL on port 5432")
    except ConnectionError as e:
        raise DatabaseError(f"Query failed: {sql!r}") from e

try:
    query_database("SELECT * FROM users")
except DatabaseError as e:
    print(f"Database error: {e}")
    print(f"Caused by: {e.__cause__}")

Custom Exceptions

Creating your own exception hierarchy makes error handling more precise and meaningful:

# Base exception for your application
class AppError(Exception):
    """Base class for all application errors."""
    pass

# Domain-specific exceptions
class ValidationError(AppError):
    """Raised when data fails validation."""

    def __init__(self, field: str, message: str) -> None:
        self.field = field
        self.message = message
        super().__init__(f"Validation failed for '{field}': {message}")

class NotFoundError(AppError):
    """Raised when a requested resource does not exist."""

    def __init__(self, resource: str, identifier: str | int) -> None:
        self.resource = resource
        self.identifier = identifier
        super().__init__(f"{resource} not found: {identifier!r}")

class AuthorizationError(AppError):
    """Raised when a user lacks permission for an action."""
    pass

# Using the custom exceptions
def get_user(user_id: int) -> dict:
    users = {1: {"name": "Alice", "role": "admin"}, 2: {"name": "Bob", "role": "user"}}
    if user_id not in users:
        raise NotFoundError("User", user_id)
    return users[user_id]

def update_user_email(user_id: int, email: str, requester_role: str) -> None:
    if requester_role != "admin":
        raise AuthorizationError("Only admins can update user emails")
    if "@" not in email:
        raise ValidationError("email", "Must contain '@'")
    user = get_user(user_id)
    user["email"] = email
    print(f"Updated {user['name']}'s email to {email}")

# Handling custom exceptions
try:
    update_user_email(99, "[email protected]", "admin")
except NotFoundError as e:
    print(f"Not found: {e}")    # Not found: User not found: 99

try:
    update_user_email(1, "invalid-email", "admin")
except ValidationError as e:
    print(f"Field '{e.field}': {e.message}")   # Field 'email': Must contain '@'

try:
    update_user_email(1, "[email protected]", "user")
except AuthorizationError as e:
    print(f"Denied: {e}")   # Denied: Only admins can update user emails

# Catching the base class catches all subclasses
try:
    update_user_email(99, "x", "user")
except AppError as e:
    print(f"App error: {type(e).__name__}: {e}")

tip type: info title: "Never use bare except"

Avoid except: (with no exception type) or except Exception: at the top level unless you absolutely must. A bare except: also catches SystemExit and KeyboardInterrupt, which can prevent your program from responding to Ctrl+C. Always catch the most specific exception type you can handle.

Context Managers and `with`

The with statement ensures resources are cleaned up even if an exception occurs — without needing a try/finally:

# File handling without with — must manually close even on error
f = open("data.txt", "w")
try:
    f.write("Hello")
finally:
    f.close()

# File handling with with — automatically closed no matter what
with open("data.txt", "w") as f:
    f.write("Hello")   # f.close() is called automatically

# Multiple context managers
with open("input.txt") as src, open("output.txt", "w") as dst:
    for line in src:
        dst.write(line.upper())

Creating Custom Context Managers

from contextlib import contextmanager

@contextmanager
def managed_resource(name: str):
    print(f"Acquiring {name}")
    try:
        yield name.upper()   # the value bound by `as`
    except Exception as e:
        print(f"Error while using {name}: {e}")
        raise
    finally:
        print(f"Releasing {name}")

with managed_resource("database connection") as res:
    print(f"Using {res}")
    # Acquiring database connection
    # Using DATABASE CONNECTION
    # Releasing database connection

`traceback` Module — Printing Full Tracebacks

import traceback

def risky_operation() -> None:
    raise RuntimeError("Something went wrong deep inside")

try:
    risky_operation()
except RuntimeError:
    # Log the full traceback without re-raising
    traceback.print_exc()
    print("Recovered and continuing...")

nextSteps

  • modules-and-packages