skip to content

Library Exception Contracts

What a published package promises to raise: one base class at the root so callers can catch broadly, documented types per failure, and no exit calls from library code. Interviewers probe API taste.

part ofPythonoverview, primer and where to startread it →
on this pageshow

questions

4

Why does a published Python package define one base exception class that all its errors subclass?

level: middleimportance: must knowfreq 58%

answer

  1. One clause instead of a list
  2. The alternative catches the caller's bugs too
  3. It marks the public error surface
  4. Leaves get raised, the root gets caught
  5. Also inherit ValueError only if permanent

basics

~10 s

So a caller can write one except PackageError to mean 'this library failed' without resorting to except Exception. The base marks the package's public error surface; the subclasses let callers who care be specific.

solid answer

~50 s

A single package-level base — `class ConversionError(Exception)` exported from the package's `__init__.py` — gives callers a precise catch-all. Without it they must either enumerate every type you might raise or fall back to `except Exception`, which also swallows their own bugs. Every public error in the package subclasses that base, so adding a new error type later cannot break an existing handler. Individual errors can also inherit a matching builtin — `class PageCountError(ConversionError, ValueError)` — so generic input-validation handlers still fire, but only if you are willing to promise that base forever. The base is a boundary marker as much as a class: anything the package raises that is *not* under it, such as a leaked driver or third-party type, is a contract violation, and each public function should document which of these types it raises.

code

python · 19 lines
python
class ConversionError(Exception):
    """Package base: callers catch this to mean 'this library failed'."""


class UnsupportedFormatError(ConversionError):
    pass


class PageCountError(ConversionError, ValueError):
    """Also a ValueError, so generic input-validation handlers still fire."""


try:
    raise PageCountError("page count exceeds the configured limit")
except ConversionError as exc:
    print("package handler caught:", type(exc).__name__)

print(issubclass(PageCountError, ValueError))
print(PageCountError.__mro__[:4])

go deeper

for a junior

Know the shape: one class inheriting Exception, every other package error inheriting from it, exported from the package root so callers can import and catch it.

for a middle

Explain what a caller gains over except Exception, why the base must be exhaustive, and the tradeoff of also inheriting a builtin such as ValueError on a leaf class.

for a senior

Talk about the base as a boundary you enforce: nothing internal leaks past it, each public function documents its raised types, and adding a new leaf never breaks an existing handler.

for a principal

Frame the error surface as API you must keep small enough to defend. Be ready to argue how many types a library should publish and how you keep that set stable across releases.

### The problem the base class solves Calling code has exactly two bad options when a library has no common error root. It can enumerate: `except (UnsupportedFormatError, PageCountError, FontMissingError, ...)` — a list that goes stale the moment you release a new error type. Or it can write `except Exception`, which catches your library's failures *and* the caller's own `TypeError` from a typo three lines up. Neither expresses the thing the caller actually means: **"the conversion library failed; fall back to the other converter."** One base class at the package root fixes that: ```python # mypkg/exceptions.py class ConversionError(Exception): """Base class for every error raised by this package.""" ``` re-exported from `__init__.py` so callers can `from mypkg import ConversionError`. Everything public the package raises subclasses it. Now the caller writes one clause, and it stays correct across releases. ### The rules that make it work **Derive from `Exception`, never `BaseException`.** Subclassing `BaseException` puts your errors alongside `SystemExit` and `KeyboardInterrupt`, where a caller's `except Exception` deliberately does not look. Your errors are errors; they belong under `Exception`. **Keep the base abstract in practice.** Raise leaf types, not the base itself. `raise ConversionError("something went wrong")` gives the caller nothing to branch on; the base exists to be *caught*, the subclasses exist to be *raised*. **Nothing escapes the base.** The base is only a real contract if it is exhaustive. A `KeyError` from your internal cache or an error class from a driver you happen to use is a leak: callers built their handling around your promise and it does not fire. That means the boundary functions of your package have to translate what comes from below into your own hierarchy before it reaches the caller. **Multiple inheritance for builtin compatibility, used sparingly.** `class PageCountError(ConversionError, ValueError)` means both `except ConversionError` and an existing generic `except ValueError` catch it, which is very friendly to callers migrating onto your library. The cost is that the builtin base becomes part of your promise: dropping `ValueError` later silently un-handles code in the wild. Add such a base only when the semantics genuinely match — `ValueError` for bad values, `LookupError` or its subclasses for missing keys, `OSError` for real I/O — and never as a decoration. **Shallow beats deep.** Two levels is usually right: the package base, and the leaves. An intermediate layer earns its place only when callers really want to catch a *group* — for example a `TransientConversionError` grouping every failure worth retrying, so a caller's retry policy is a single clause. Every extra level is another relation you have promised not to change. ### Documenting what each function raises The class hierarchy is only half the contract; the other half is *which* of those types a given function can raise. That belongs in the docstring, in an explicit `Raises:` section, function by function. The rule to state out loud in an interview: **anything documented is a promise, anything undocumented is private and may change.** Without that line, every incidental `TypeError` your implementation happens to produce becomes something a user can reasonably claim to depend on. Practical consequences: keep the documented set small; do not document a type a function raises only through a bug; and if a function can raise nothing but the base's subclasses, say so, because that sentence is what lets a caller write a single handler with confidence. ### Naming and packaging details Name error classes with an `Error` suffix (`ConversionError`, not `Conversion` or `ConversionException`) — that is what the standard library does and what readers scan for. Do not shadow builtin names. Keep the classes in one module (`exceptions.py` or `errors.py`) so the hierarchy is readable in one screen, and re-export the base — and usually every public leaf — from the package root, because a caller who cannot import the type cannot catch it. Since Python 3.11 there is one more shape to consider: a batch operation that fails partway can raise an `ExceptionGroup` whose contained exceptions are your leaf types, and callers unpack it with `except*`. That is compatible with a base class — the group holds your types — but it is a genuinely different signature, so it must be documented as such rather than slipped in.

  • Should the base class ever be raised directly?
    No. Raise leaf types; the base exists to be caught. `raise ConversionError(...)` forces every caller into string matching to find out what actually went wrong, and it makes the failure impossible to handle selectively later without breaking the message-parsing people wrote in the meantime.
  • How do you decide whether an error should also inherit a builtin like ValueError?
    Only when the semantics match exactly and you are willing to keep that base forever. The upside is that existing generic handlers catch it; the downside is that the builtin becomes part of your published contract, and removing it later silently unhandles code in the wild. Decorative mixing in of builtins is a common mistake.
  • Where do you document which exceptions a function raises?
    In the function's own docstring, in an explicit Raises section, naming the concrete types. Treat documented types as a promise and everything else as private and free to change. Module-level prose is not enough, because the caller reads the function they are calling.
  • Is a deep exception hierarchy better than a flat one?
    Usually not. Two levels — a package base plus leaves — covers most needs. Add an intermediate class only when callers genuinely want to catch a group, such as every transient failure worth retrying. Each extra level is another relation you have promised never to change.

saying these in an interview costs you the question

  • Tells callers to use except Exception instead
  • Derives the package base from BaseException
  • Raises the base class directly with a string message
  • Lets internal or third-party error types escape the package
  • Builds a five-level hierarchy nobody catches at
  • Documents nothing about which functions raise what

context

open as a page

Why is calling sys.exit() from Python library code a design defect?

level: juniorimportance: should knowfreq 38%

basics

~20 s

sys.exit() raises SystemExit, which derives from BaseException, so a caller's except Exception never sees it and the whole process dies. Library code should raise its own exception and let the application decide whether to exit.

open as a page

When should a library call warnings.warn for a soft failure instead of raising an exception?

level: seniorimportance: should knowfreq 42%

basics

~20 s

Warn only when the call still delivered what it promised and the caller should change something next time. An incomplete or wrong result — a silently truncated document — is a failure: raise it, or state it in the return value.

open as a page

How would you evolve a published library's exception hierarchy without breaking existing except clauses?

level: principalimportance: should knowfreq 30%

basics

~20 s

Only add downward. A new class that subclasses an already-documented type keeps every existing handler working; removing a class, dropping a base, or raising a different type from the same function silently unhandles code you cannot see.

open as a page