Skip to content
advanced

Designing Python Exception Boundaries: Custom Exceptions, Chaining, Re-Raising, and Exception Groups

A boundary that leaks sqlite3.OperationalError into your domain code has not failed loudly. It has failed quietly, by making every caller learn your…

Published 2026-09-11Updated 2026-09-129 min read
Detailed view of network cables plugged into a server rack in a data center.
Detailed view of network cables plugged into a server rack in a data center. Photo by Brett Sayles on Pexels.

A boundary that leaks sqlite3.OperationalError into your domain code has not failed loudly. It has failed quietly, by making every caller learn your storage engine.

I have debugged this exact class of problem more times than I care to count. A service logs ConfigError: invalid configuration with no traceback. The real cause — a FileNotFoundError three layers down — is gone. Someone spends an afternoon adding print statements to find what a single raise ... from would have preserved.

The weak mental model underneath is treating exceptions as messages to catch. The stronger model: an exception boundary is a translation layer with an explicit vocabulary. The design question is not "where do I put the try/except?" It is "what crosses this seam, what stays behind, and what evidence survives the crossing?"

What an Exception Boundary Actually Is

A boundary is the seam where one layer's vocabulary of failure ends and another's begins. Transport versus domain. Adapter versus core. Agent tool versus orchestrator. At that seam, you make three decisions:

  1. What failure types may cross.
  2. What context must survive.
  3. Who is allowed to decide recovery.

The split that matters most is domain failures versus infrastructure failures. A domain failure — invalid input, a business rule violated, a required field missing — tells the caller something they can act on. An infrastructure failure — network timeout, disk full, connection refused — tells the caller something about your implementation, not about their request. If a ConnectionError escapes your repository layer into your order-processing logic, every caller now needs to know you use a database. That coupling is the leak.

Catching broad Exception at a boundary is a contract violation for the same reason. It erases the distinction the caller needs to decide whether to retry, surface an error to a user, or fail the request. The boundary's job is to translate, not to absorb.

Knowledge check

Check your understanding

Answer this question before you continue.

A repository uses a database internally and encounters a connection refusal while loading an order. What should its public boundary generally do?
Scenario Interpretation

Focus: Distinguish domain failures from infrastructure failures and choose an appropriate boundary translation.

Designing a Custom Exception Hierarchy That Earns Its Depth

The pattern I reach for: one module-level base exception per package, then category subclasses only where a caller would actually catch differently.

# myapp/exceptions.py
class MyAppError(Exception):
    """Base for all myapp failures."""

class ValidationError(MyAppError):
    """Input failed a domain rule."""

class StorageError(MyAppError):
    """Persistence layer failed."""

That is often enough. The failure mode on one side is a hierarchy so deep that every catch site imports five types. The failure mode on the other side is a hierarchy so flat that callers catch MyAppError and lose all precision. The decision rule: add a subclass when a caller needs to branch on it. Otherwise reuse the parent.

Carry structured attributes instead of encoding them in the message string:

class ValidationError(MyAppError):
    def __init__(self, field: str, reason: str):
        super().__init__(f"invalid {field}: {reason}")
        self.field = field
        self.reason = reason

The message is for humans reading a traceback. The attributes are for code that needs to branch, log structured fields, or build a user-facing error response. Keep super().__init__() with a readable message; add attributes for machine-readable context. A caller that has to parse str(exc) to find the field name is a caller you have failed.

One boundary note: type annotations document what a function raises, they do not enforce it at runtime. def load(path: str) -> Config: ... with a docstring saying Raises: ConfigError is a promise, not a guarantee. The runtime contract lives in the code.

Knowledge check

Check your understanding

Answer this question before you continue.

When should a package add a specialized exception subclass instead of reusing its existing parent exception?
Comparison Reasoning

Focus: Choose exception hierarchy depth based on whether callers need different recovery branches.

Chaining and Re-Raising Without Losing the Cause

A left-to-right flow shows FileNotFoundError inside a storage layer entering an exception boundary, becoming ConfigError for the caller, while a secondary traceback path preserves the original FileNotFoundError as the direct cause.
Translate the exception type at the boundary, but keep the original traceback connected with raise ... from.

This is where most debugging time is won or lost. The mechanics are small; the consequences are large.

raise NewError(...) from exc sets __cause__ and produces an explicit "direct cause" chain. Omitting from sets __context__ implicitly and produces "during handling of the above exception." Both preserve the original. The difference is intent: from says "I am translating this deliberately." The implicit form says "this happened while I was handling that."

def load_config(path: str) -> dict:
    try:
        with open(path) as f:
            return json.load(f)
    except FileNotFoundError as exc:
        raise ConfigError(f"config file missing: {path}") from exc
    except json.JSONDecodeError as exc:
        raise ConfigError(f"invalid JSON in {path}") from exc

The printed traceback now shows both frames: the ConfigError at the boundary, and underneath it, The above exception was the direct cause of the following exception: followed by the FileNotFoundError and its original stack. The caller catches ConfigError. The debugger sees the file path.

Use from None when the underlying exception is noise or leaks internals the caller must not depend on. That is a deliberate, documented choice, not a default. Suppressing the cause means the next person debugging a failure has less to work with.

The re-raising trap: bare raise inside an except block re-raises the current exception with its traceback intact. raise exc re-raises but resets the traceback origin to the current line. That second form is a common accidental bug — it looks equivalent and silently rewrites where the traceback points.

When you handle partially — log, add context, then let it propagate — use add_note rather than constructing a new exception that drops the stack:

try:
    process(record)
except ValidationError as exc:
    exc.add_note(f"record_id={record.id}")
    raise

The note appears in the traceback output. The original stack survives. The caller sees the same type it expected.

Knowledge check

Check your understanding

Answer this question before you continue.

Which replacement preserves the original exception type and traceback after adding a record identifier?
Debugging

Focus: Preserve the original traceback and exception type when adding context to a handled failure.

try:
    process(record)
except ValidationError as exc:
    # add context, then propagate
    ...

Aggregating Failures with ExceptionGroup and except*

ExceptionGroup wraps multiple unrelated exceptions. BaseExceptionGroup can wrap any BaseException; ExceptionGroup only wraps Exception subclasses. The BaseExceptionGroup constructor returns an ExceptionGroup when all members are Exception instances, so the selection is automatic. The ExceptionGroup constructor raises TypeError if any member is not an Exception.

except* matches subgroups by type rather than catching the whole group. A handler processes only the matching members and leaves the rest to propagate.

try:
    async with asyncio.TaskGroup() as tg:
        tg.create_task(fetch_a())
        tg.create_task(fetch_b())
except* TimeoutError as eg:
    for exc in eg.exceptions:
        log.warning("timeout: %s", exc)

The semantics that surprise people: unmatched members are re-raised as a new group. Exceptions raised explicitly inside a handler are combined into a new group with their own cause, context, and traceback. Re-raised and unhandled members keep the original group's metadata. The interpreter combines all of these into the result it raises.

If you subclass ExceptionGroup, you must override derive() so subgroup() and split() return your type. The copied __traceback__, __cause__, __context__, and __notes__ fields are handled for you — derive() only needs to return a new instance with the same message and the given exceptions.

The design tension worth naming: a boundary that only sometimes wraps failures in a group forces callers to handle both shapes. This is the strict-versus-loose semantics argument that concurrent libraries have debated — always wrapping, even a single exception, makes except FooError consistently fail to catch across the boundary, which is easier to reason about but breaks existing handlers. Pick one policy and document it.

When not to use groups: sequential code with one failure path gains nothing but an extra unwrapping step.

Knowledge check

Check your understanding

Answer this question before you continue.

A group contains two TimeoutError instances and one ValueError. The code has only `except* TimeoutError as eg`, and the handler does not raise. What happens to the ValueError?
Output Prediction

Focus: Predict how except-star handles matching and unmatched members of an exception group.

try:
    raise ExceptionGroup("work", [TimeoutError("a"), ValueError("bad"), TimeoutError("b")])
except* TimeoutError as eg:
    handle_timeouts(eg)

Boundaries in Concurrent and Agent Systems

Fan-out work is where exception boundaries pay off most, because partial failure is the normal case. With asyncio.TaskGroup or a nursery, multiple tasks can fail at once. The boundary must decide: fail fast, or collect all failures?

That is an aggregation contract, and it should be explicit. Does the caller need every failure, the first failure, or a summary plus the raw group? Collecting all failures is more informative but delays the first response. Fail-fast is cheaper but hides correlated errors — if three tasks failed for the same upstream reason, you want to see that pattern, not just the first one.

For agent tool boundaries, the adapter should translate transport and parsing failures into a domain failure the orchestrator can reason about, and preserve the raw error for the trace. A tool that raises json.JSONDecodeError into the orchestrator has leaked its parsing strategy. A tool that raises ToolInputError(field="query", reason="not valid JSON") from exc has given the orchestrator something to act on.

Watch for cancellation and cleanup failures arriving alongside real errors. A handler that only catches the expected type silently drops the rest — including the CancelledError that tells you the task was interrupted. except* handles this correctly by matching subgroups; a plain except ExpectedError does not.

Observability rule: log the group once at the boundary with structured fields, not inside every task. Keep the original tracebacks reachable. Double-logging at every layer buries the real origin in repetition.

A Boundary Checklist and the Failure Modes It Prevents

Compress the model into a procedure:

  1. Name the boundary.
  2. Define the crossing vocabulary — which types may pass.
  3. Translate with raise ... from.
  4. Preserve machine-readable attributes.
  5. Decide the aggregation policy.
  6. Document what callers may catch.

Each rule prevents a specific bug:

  • The swallowed exception. A bare except that logs and continues leaves the caller with a wrong result instead of an error. The failure surfaces later, far from its cause.
  • The leaky boundary. Infrastructure types escaping into domain code couple every caller to your implementation.
  • The flattened group. Unwrapping an ExceptionGroup into a single generic error discards the members and the pattern they reveal.
  • The double-logged traceback. Logging at every layer buries the origin.

And the counterweight: a plain try/except is genuinely enough for single-layer scripts with one caller and no translation needed. Do not build a hierarchy for a 40-line script. The hierarchy earns its depth when a caller would catch differently — not before.

Pick one boundary in your current codebase. Write down the failure vocabulary allowed to cross it. Then audit every except clause on that seam for swallowed causes, leaked infrastructure types, and unwrapped groups. Translate at the boundary, preserve the cause, aggregate only when failures are genuinely concurrent. The next person debugging a 2 a.m. incident will thank you — and it will probably be you.

Knowledge check

Final check

Finish the article by checking the ideas you just learned.

An agent tool receives malformed JSON from an upstream response. Which boundary behavior best matches the article's contract?
Question 1 of 2Scenario Interpretation

Focus: Design an agent-tool boundary that exposes an actionable domain failure without leaking implementation details.

Which design best follows the article's complete boundary checklist for a concurrent operation?
Question 2 of 2Comparison Reasoning

Focus: Apply the boundary checklist to select an exception design that preserves causes, contracts, and concurrent failure information.

Related sites

Build the foundations behind advanced AI systems

Use LearnLLMFast for practical LLM application foundations and LearnPyFast for the Python mechanisms that support implementation work.

LLM tutorialstutorial

LearnLLMFast

Practical LLM tutorials for builders who want to understand prompting, workflows, agents, and AI applications.

LLMAIBuilders
Visit LearnLLMFast
Python tutorialstutorial

LearnPyFast

Beginner-friendly Python tutorials, examples, and learning paths for practical programming foundations.

PythonProgrammingBeginners
Visit LearnPyFast

Keep exploring

Related AI engineering tutorials

Continue with adjacent system layers, implementation patterns, and current AI engineering ideas.