Skip to content

description: "Custom Exceptions & Context Managers — A custom exception is just a class that inherits from Exception (directly or indirectly)."---

04 · Custom Exceptions & Context Managers

🎥 Video walkthrough

Level 1 covered catching built-in exceptions. Real projects usually define their own exception types to represent domain-specific failures, and rely on context managers (the with statement) to guarantee cleanup code runs even when something goes wrong.

Defining custom exceptions

A custom exception is just a class that inherits from Exception (directly or indirectly).

class InsufficientFundsError(Exception):
    """Raised when a withdrawal exceeds the available balance."""


class Account:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(
                f"cannot withdraw {amount}, balance is {self.balance}"
            )
        self.balance -= amount
        return self.balance


account = Account(100)
try:
    account.withdraw(150)
except InsufficientFundsError as e:
    print(f"Transaction failed: {e}")

Building an exception hierarchy

Group related errors under a common base class so callers can catch broadly or narrowly depending on what they need.

class AppError(Exception):
    """Base class for all errors raised by this application."""


class ValidationError(AppError):
    """Input failed validation."""


class NotFoundError(AppError):
    """A requested resource doesn't exist."""


def get_user(users, user_id):
    if not isinstance(user_id, int):
        raise ValidationError(f"user_id must be an int, got {type(user_id).__name__}")
    if user_id not in users:
        raise NotFoundError(f"no user with id {user_id}")
    return users[user_id]


users = {1: "Ada", 2: "Grace"}

for bad_id in ("x", 99):
    try:
        get_user(users, bad_id)
    except AppError as e:
        # catches ValidationError AND NotFoundError since both are AppError
        print(f"{type(e).__name__}: {e}")

Adding structured data to exceptions

Override __init__ to attach extra context beyond the message string.

class APIError(Exception):
    def __init__(self, message, status_code):
        super().__init__(message)
        self.status_code = status_code


try:
    raise APIError("rate limit exceeded", status_code=429)
except APIError as e:
    print(f"[{e.status_code}] {e}")   # [429] rate limit exceeded

Re-raising and exception chaining

def load_config(path):
    try:
        with open(path) as f:
            return f.read()
    except FileNotFoundError as e:
        raise AppError(f"config file missing: {path}") from e


try:
    load_config("missing.cfg")
except AppError as e:
    print(e)
    print(e.__cause__)   # the original FileNotFoundError, preserved for debugging

The with statement

with guarantees a "cleanup" step runs when a block ends — even if an exception is raised inside it. You've already used this for files:

with open("notes.txt", "w") as f:
    f.write("hello")
# file is closed automatically here, even if write() had raised

Writing your own context manager (class-based)

Any object with __enter__ and __exit__ methods can be used with with.

class Timer:
    def __enter__(self):
        import time
        self.start = time.perf_counter()
        return self   # this becomes the "as" variable

    def __exit__(self, exc_type, exc_value, traceback):
        import time
        elapsed = time.perf_counter() - self.start
        print(f"elapsed: {elapsed:.4f}s")
        return False  # False (or None) means: don't suppress exceptions


with Timer():
    total = sum(range(1_000_000))

__exit__ receives details about any exception that occurred inside the block. Returning True from __exit__ suppresses the exception; returning False/None (the default) lets it propagate normally.

contextlib.contextmanager — the easy way

Writing a full class for simple context managers is often overkill. @contextmanager turns a generator function into one instead.

from contextlib import contextmanager

@contextmanager
def managed_resource(name):
    print(f"acquiring {name}")
    try:
        yield name          # everything before yield is __enter__, after is __exit__
    finally:
        print(f"releasing {name}")


with managed_resource("database connection") as resource:
    print(f"using {resource}")

# acquiring database connection
# using database connection
# releasing database connection

The try/finally ensures the release code runs even if the with block raises an exception.

contextlib.suppress

from contextlib import suppress

with suppress(FileNotFoundError):
    import os
    os.remove("temp_file_that_might_not_exist.txt")
# no crash even if the file doesn't exist

Cheat sheet

Tool Use for
class MyError(Exception) a new, meaningful error type
raise X from Y chaining — preserve the original cause
class with __enter__/__exit__ reusable context manager needing state
@contextlib.contextmanager quick context manager from a generator
contextlib.suppress(Err) ignore a specific, expected exception

How It Actually Works

Custom exceptions are ordinary classes — the only thing that makes InsufficientFundsError catchable is that it inherits (transitively) from BaseException. except AppError works for both ValidationError and NotFoundError because the except clause runs issubclass(type(exc), AppError), and both are subclasses. raise X from Y sets X.__cause__ = Y and a __suppress_context__ flag; an unhandled exception raised inside an except block automatically gets __context__ set to the one being handled. The traceback printer walks all three (__traceback__, __cause__, __context__) to produce the full chained report.

The with statement is a precise protocol, not a special case of try:

  1. Evaluate the expression (open(...), Timer()), then call its __enter__. The return value is bound to the as name.
  2. The compiler registers __exit__ so it runs no matter how the block ends.
  3. On normal exit: __exit__(None, None, None).
  4. On an exception: __exit__(exc_type, exc_value, traceback). If __exit__ returns a truthy value, the interpreter swallows the exception; if it returns falsy/None, the exception continues propagating after cleanup.

@contextmanager bridges the two worlds. It wraps your generator in a helper object whose __enter__ calls next(gen) (running everything up to yield and returning the yielded value) and whose __exit__ either calls next(gen) again (normal path) or gen.throw(exc) (exception path) — which is why you need try/finally inside the generator: gen.throw makes the yield expression raise, and only a finally guarantees your release code still runs.

Exercise

Define a DatabaseConnectionError(AppError) exception, and write a @contextmanager function db_connection(url) that prints "connecting", yields a fake connection object, and prints "closing" in a finally block even if the code using the connection raises. Then simulate a failure inside the with block and confirm "closing" still prints before the exception propagates.