Python Atlas
Core language

Exceptions and context managers

Design explicit failure boundaries, preserve exception context, aggregate concurrent errors, and guarantee resource cleanup.

Exceptions separate the successful path from failure handling. A useful exception identifies what operation failed, carries relevant structured context, and is caught at a layer that can actually recover or translate it.

Raise specific exceptions

class ConfigurationError(Exception):
    def __init__(self, message: str, *, key: str | None = None) -> None:
        super().__init__(message)
        self.key = key


def require_setting(settings: dict[str, str], key: str) -> str:
    try:
        return settings[key]
    except KeyError as error:
        raise ConfigurationError(
            f"missing required configuration: {key}",
            key=key,
        ) from error

The domain exception tells callers what failed. raise ... from error explicitly chains the low-level cause, preserving useful debugging context. Use raise ... from None only when the lower-level exception is an irrelevant implementation detail and suppressing its display improves the API.

Do not put secrets, tokens, full queries, or sensitive payloads in exception messages; they often reach logs and error trackers.

Catch only what you can handle

def load_port(raw: str | None) -> int:
    if raw is None:
        return 8000

    try:
        port = int(raw)
    except ValueError as error:
        raise ConfigurationError("PORT must be an integer") from error

    if not 1 <= port <= 65_535:
        raise ConfigurationError("PORT must be between 1 and 65535")
    return port

Catch close to the operation when translating a known failure. Catch near an application boundary when logging, returning an HTTP response, or deciding whether a process can continue.

Avoid broad except Exception inside reusable code. It can hide programmer errors such as TypeError and AttributeError. A top-level worker loop may catch Exception to isolate jobs, but it should log the traceback and make a deliberate retry or failure decision. Bare except: also catches KeyboardInterrupt and SystemExit, which generally must propagate.

else and finally

try:
    payload = read_payload()
except OSError as error:
    report_io_failure(error)
else:
    process(payload)
finally:
    release_temporary_state()

The else block runs only when the try suite succeeds. Keeping process outside the try prevents its unrelated exceptions from being caught as read failures. finally runs whether the suite returns, raises, or succeeds.

Never return from finally: it can suppress an active exception or replace a previous return value.

Exception groups

Concurrent operations may fail independently. Python 3.11 introduced ExceptionGroup and except* so callers can handle matching subgroups:

def validate_batch(values: list[str]) -> None:
    errors: list[Exception] = []
    for index, value in enumerate(values):
        if not value:
            errors.append(ValueError(f"item {index} is empty"))

    if errors:
        raise ExceptionGroup("batch validation failed", errors)


try:
    validate_batch(["ok", "", ""])
except* ValueError as group:
    for error in group.exceptions:
        print(error)

Use groups when failures are truly independent and preserving all of them helps the caller. A normal exception remains clearer for a single causal path.

The context manager protocol

with manager as value calls manager.__enter__(), executes the body, then calls manager.__exit__(type, value, traceback). Cleanup runs even when the body raises.

from types import TracebackType


class Transaction:
    def __enter__(self) -> "Transaction":
        self.begin()
        return self

    def __exit__(
        self,
        exc_type: type[BaseException] | None,
        exc_value: BaseException | None,
        traceback: TracebackType | None,
    ) -> bool:
        if exc_type is None:
            self.commit()
        else:
            self.rollback()
        return False

    def begin(self) -> None:
        print("begin")

    def commit(self) -> None:
        print("commit")

    def rollback(self) -> None:
        print("rollback")

Returning false tells Python to propagate any body exception. Returning true suppresses it and should be reserved for managers explicitly designed to handle that failure. Accidental truthy returns are dangerous.

Generator-based context managers

For simple acquire/yield/release behavior, contextlib.contextmanager removes protocol boilerplate:

from collections.abc import Iterator
from contextlib import contextmanager
from pathlib import Path
from tempfile import TemporaryDirectory


@contextmanager
def temporary_workspace() -> Iterator[Path]:
    with TemporaryDirectory() as directory:
        yield Path(directory)

Code before yield enters the context; code after it exits. Wrap yield in try/finally when cleanup is not already delegated to another manager. The decorated generator must yield exactly once.

ExitStack composes a dynamic number of context managers and cleanup callbacks. AsyncExitStack does the same for asynchronous resources. Prefer these tools to manually nested conditional cleanup.

Asynchronous context managers

An async context manager implements __aenter__ and __aexit__ and is consumed with async with. Use it when acquisition or release itself awaits I/O, such as opening a client session or transaction. Do not perform blocking cleanup in an async method; it stalls the event loop.

Tradeoffs and pitfalls

  • Exceptions avoid error-code plumbing but make nonlocal control flow possible.
  • Custom exception hierarchies help callers catch at the right level, but dozens of single-use types add noise.
  • Using exceptions for expected high-frequency branching may be less readable than an explicit result.
  • Context managers make cleanup deterministic, unlike __del__.
  • Catching, logging, and re-raising at every layer creates duplicate logs.
  • Retrying requires a defined transient failure set, backoff, and an idempotency strategy.

Practice

  1. Translate a KeyError into a domain exception while preserving the cause.
  2. Refactor a try block so its success-only work moves into else.
  3. Implement a context manager that restores a changed setting after failure.
  4. Use ExitStack to open a runtime-sized list of files and ensure all close.

LEARNING RECORD

Finish this lesson

Mark it complete when you can explain the main decision without looking.

KNOWLEDGE CHECK

Exceptions and context managers

1What does `raise ConfigurationError(...) from error` accomplish?
2A transaction manager rolls back after an exception and returns `False` from `__exit__`. What follows?