Here is an uncomfortable fact about a codebase that passes mypy --strict: the
signature
def charge(card: Card, cents: int) -> Receipt: ...
is a complete lie about half of what this function does. It raises four different exceptions, two of which the caller must handle to be correct, and none of that appears anywhere a machine can check. The exception hierarchy is part of the contract and the type system cannot express it.
Java tried. Checked exceptions put the raises-clause in the signature, and the
industry concluded that the cure was worse than the disease — the failure mode
was throws Exception on everything, or empty catch blocks to satisfy the
compiler, which is strictly worse than no annotation at all because now the
compiler has certified the lie. Python never tried, and there is no serious
proposal to.
So the discipline has to come from somewhere else. This article is about the four places it can come from.
1. Documenting the raises-contract, and treating it as a contract
The cheapest option and the one most teams under-invest in. If an exception is part of your public API — as designed in item 6.1 — then it belongs in the docstring, in the same tone as the parameters:
Raises:
TransientPaymentError: gateway unavailable; retry after ``retry_after``.
CardDeclinedError: the issuer refused; do not retry.
Two rules make this worth doing. Document only what the caller can act on —
listing every internal ValueError is noise. And when you add a raise, update
the docstring in the same commit, because a raises-clause nobody maintains is a
raises-clause nobody trusts.
The honest limitation: nothing checks this. Which is why the next three exist.
2. Result as a boundary technique
Move the error into the return type where the checker can see it:
type Result[T, E] = Ok[T, E] | Err[T, E]
def charge(card: Card, cents: int) -> Result[Receipt, PaymentError]: ...
Now the failure is in the signature, mypy knows about it, and a caller who
ignores the error case gets an error — because they cannot reach .value
without narrowing first.
And now the honest part, which most articles on this topic skip: the
ergonomics in Python are genuinely worse than in a language with a ?
operator. In Rust, chaining five fallible calls is five extra characters. In
Python it is five if isinstance(...) blocks, or a .map() chain that reads
sideways and falls apart the moment one step needs two inputs. There is no
syntax support and there is not going to be any.
So this is not a house style. It is a technique for specific places:
- One library boundary, where “this can fail and the caller must decide” is the entire point of the API.
- A parser or validator, where failure is an expected outcome rather than an exceptional one, and where you want to accumulate several failures rather than stop at the first.
- A pipeline stage whose caller is a loop that must not stop.
Everywhere else, exceptions win on ergonomics and lose nothing that matters.
💡If Result makes the error checkable, why not use it everywhere and delete the exception hierarchy?
click to reveal
Because propagation is the feature exceptions provide and Result takes away.
A Result must be threaded by hand through every intermediate frame. If a() calls b() calls c() and only c can fail, exceptions let a handle it and b stay completely ignorant. With Result, b‘s signature changes too — it must return Result as well, and unwrap-and-rewrap in the middle. Every layer between the raise and the handler pays.
That is exactly the cost Rust’s ? operator exists to erase, and Python has no equivalent. Without it, a codebase-wide Result habit means every function returns Result, every call site is three lines, and the error type union grows monotonically as it bubbles.
There is also a correctness argument. Exceptions are unmissable: ignore one and your program stops. A Result you forget to check is a silent bug — unless the checker catches it, and the checker only catches it if you actually read the value. mypy will not complain about a discarded return.
The synthesis most teams land on: exceptions for control flow and for genuinely exceptional conditions; Result at one or two boundaries where the failure is an expected outcome the caller must handle, with the unwrapping confined to that boundary.
3. Typing context managers so __exit__ does not eat exceptions
A subtle one that bites in production. The signature of __exit__ is:
def __exit__(self, exc_type, exc, tb) -> bool | None: ...
A truthy return means “I handled it, suppress it”. Returning None — or
False — means “let it propagate”. So this is a bug:
def __exit__(self, exc_type: type[BaseException] | None,
exc: BaseException | None,
tb: TracebackType | None) -> bool:
self.close()
return True # every exception inside the with-block vanishes
and the more insidious version is annotating -> bool and then returning
whatever some cleanup helper returned. If your __exit__ does not deliberately
suppress, annotate it -> None and return nothing. That annotation makes it
impossible to accidentally suppress, which is worth more than the flexibility
you gave up.
contextlib.contextmanager-decorated generators have the same trap in a
different shape: a bare except around the yield that does not re-raise
swallows everything the body raised.
4. Typing decorators so the wrapped signature survives
A retry decorator that loses its wrapped function’s types has undone the whole point of the gate:
def retry(fn: Callable[..., Any]) -> Callable[..., Any]: # everything is Any now
ParamSpec (PEP 612) preserves it:
def retry[**P, R](fn: Callable[P, R]) -> Callable[P, R]:
@functools.wraps(fn)
def wrapper(*args: P.args, **kwargs: P.kwargs) -> R:
...
return wrapper
[**P, R] is the PEP 695 spelling (3.12+). Callers of the decorated function
keep exact argument checking and the exact return type. Without it, one
decorator anywhere in a call chain silently converts a strictly typed module
into an untyped one — and mypy --strict will not tell you, because Any is
compatible with everything by design.
💡--strict includes --disallow-untyped-decorators. Does that not already catch this?
click to reveal
No — it catches a different thing, and the distinction is worth being precise about.
--disallow-untyped-decorators fires when a decorator has no annotations at all, so applying it would make a typed function untyped. It is a real check and it is worth having.
Callable[..., Any] -> Callable[..., Any] is fully annotated. It satisfies the flag completely. And it destroys every signature it touches: the decorated function now accepts any arguments and returns Any, so charge("not a card", "not an int") type-checks cleanly.
This is the general shape of the gap --strict leaves: it enforces the presence of annotations, not their precision. Any is explicit, permitted, and infectious. Catching it needs either review discipline or an additional rule — --disallow-any-explicit, or ruff’s ANN401 for Any in argument positions.
The verdict
There is no mechanism that makes Python’s error paths checkable the way its value paths are. What you have instead:
- Design the hierarchy so callers can branch on it (6.1).
- Document the raises-contract for anything callers must act on, and maintain it.
-
Return a
Resultat the one or two boundaries where failure is an expected outcome — and nowhere else. -
Annotate
__exit__as-> Noneunless you truly suppress. -
Preserve signatures through decorators with
ParamSpec. - Test the failure paths, because the checker will not.
That last one is not a consolation prize. In the absence of checked exceptions, the test suite is the raises-contract, and it is the only executable one you will ever have.