Skip to content

Error Handling

Exceptions should travel up the call stack until a layer can recover or an outer boundary can convert the failure into a user-visible or operational result. As an exception crosses abstraction layers, each layer that knows useful facts should add that context without discarding the original cause.

Bubbling Context

Low-level code raises precise mechanical failures. Higher layers translate those failures into their own vocabulary and add facts that only they know: which record was being processed, which tenant or request was active, which operation failed, or which configuration source was selected.

When a layer has meaningful context, catch the relevant exception and raise a new, more useful exception with from error:

def read_profile(path: Path) -> Profile:
    try:
        return parse_profile(path.read_text())
    except OSError as error:
        raise ProfileStorageError(f"Could not read profile at {path}") from error


def load_tenant(tenant_id: str, profile_path: Path) -> Tenant:
    try:
        profile = read_profile(profile_path)
        return Tenant.from_profile(tenant_id, profile)
    except ProfileError as error:
        raise TenantConfigurationError(
            f"Could not configure tenant {tenant_id!r}"
        ) from error

The resulting traceback tells the complete story: the filesystem operation failed, therefore the profile could not be read, therefore the tenant could not be configured. The outer exception is useful to the current caller; the chained causes retain the diagnostic detail needed to find the root failure.

Apply these rules at each layer:

  • Add context when the layer knows information that would help a caller, operator, or debugger understand the failed operation.
  • Use an exception type from the current abstraction. Callers of tenant configuration should not need to understand storage-library exceptions.
  • Chain explicitly with raise NewError(...) from error; this records the prior exception in __cause__ and renders it in the traceback.
  • If the layer has no useful context and cannot recover, do not catch the exception. Natural propagation preserves the traceback.
  • Use bare raise when work such as rollback or metrics must happen but the exception type and message should remain unchanged.
  • Use raise ... from None rarely, only when the original failure is irrelevant implementation detail and hiding it will not impair diagnosis.
  • Log once where the failure becomes an operational outcome. Intermediate layers should enrich and re-raise rather than emit duplicate logs.

On Python 3.11+, BaseException.add_note can attach supplementary context while preserving the same exception type. Prefer a new chained exception when crossing an abstraction boundary; use a note when the existing type remains the correct public contract.

Common Exception Meanings

Choose a built-in exception when its established meaning accurately describes the failure. This makes APIs predictable and lets callers catch familiar categories.

Exception Intended meaning and use
TypeError An object has the wrong type, or an operation does not support that type. Use it for bytes where str is required, not for a malformed str.
ValueError The type is acceptable but the value is invalid, such as a negative count or an unknown enum string. Prefer a more specific exception when one exists.
KeyError A requested mapping key is absent. It is appropriate for mapping-like APIs; use a domain exception when the failure means more than lookup.
IndexError A sequence index is outside its valid range. Non-integer indexing is a TypeError.
AttributeError Attribute lookup or assignment failed. Raise it in dynamic attribute protocols so hasattr and introspection behave correctly.
LookupError Common base for KeyError and IndexError; useful when either lookup failure is handled identically.
OSError An operating-system or I/O operation failed. Prefer specific subclasses such as FileNotFoundError, PermissionError, or FileExistsError when known.
ConnectionError Base category for broken, aborted, refused, or reset connections. Catch a subtype when recovery differs.
TimeoutError An operation exceeded its time limit. Include which operation and effective timeout when translating it.
ImportError An import failed; ModuleNotFoundError specifically means the module could not be located. Do not use either for arbitrary plugin initialization failures.
RuntimeError A failure does not fit a more specific category. Treat it as a last general-purpose choice, not the default for every domain error.
NotImplementedError A base operation requires a subclass implementation or implementation is intentionally pending. It does not mean an input or operation is unsupported; NotImplemented is a separate operator-protocol sentinel.
AssertionError An internal assertion failed. Assertions document developer invariants and may be disabled; never use them to validate user input or required runtime conditions.

KeyboardInterrupt, SystemExit, and GeneratorExit inherit directly from BaseException because ordinary application handlers should not swallow them. Derive user exceptions from Exception, and do not catch BaseException in routine error handling.

See the official built-in exception hierarchy for specialized arithmetic, Unicode, iteration, syntax, and OS exceptions.

Custom Exceptions

Create a custom exception when the domain needs a stable failure category that no built-in communicates well. Custom types are especially useful when:

  • callers should recover from a domain failure without depending on a database, HTTP, parser, or filesystem implementation;
  • several low-level exceptions need to become one stable public contract;
  • callers need to handle related failures as a family or distinguish meaningful subcategories;
  • the exception should carry structured fields that handlers can inspect without parsing its message.

Do not create a custom type merely to rename a precise built-in at a private call site. If callers only need to know that an argument has an invalid value, ValueError is clearer than a one-use wrapper.

Define a Small Hierarchy

Start with one package or subsystem base derived from Exception, then add subclasses only for distinctions callers use:

class OrderImportError(Exception):
    """Base class for failures exposed by order importing."""


class OrderSourceError(OrderImportError):
    """The configured order source could not be read."""

    def __init__(self, path: Path) -> None:
        self.path = path
        super().__init__(f"Could not read order source at {path}")


class InvalidOrderError(OrderImportError):
    """An order violates the import contract."""

    def __init__(self, order_id: str, reason: str) -> None:
        self.order_id = order_id
        self.reason = reason
        super().__init__(f"Order {order_id!r} is invalid: {reason}")

Callers can catch OrderImportError for all import failures or a subtype for targeted recovery. Store structured, non-secret attributes for programmatic handling, and still provide a concise human-readable message through super().__init__.

Design Rules

  • End exception class names in Error unless another standard convention is clearer.
  • Keep exception classes simple. Put recovery behavior in handlers, not on the exception object.
  • Prefer one inheritance path. Avoid multiple inheritance from built-in exception types because implementations may have incompatible layouts and args behavior.
  • Subclass a specific built-in only when the custom error genuinely retains that standard meaning and callers benefit from catching the built-in category. Otherwise derive the domain base directly from Exception.
  • Keep the hierarchy shallow and based on handling decisions, not every possible failure message.
  • Translate third-party exceptions at the boundary where they would otherwise leak into the public API, and preserve them with explicit chaining.

References: errors and exceptions tutorial, built-in exceptions, exception chaining, and user-defined exceptions.