Errors as Design
Exception hierarchies that let callers catch the right thing, chaining so the original cause survives, and deciding when a failure should raise rather than return.
Exception hierarchies that let callers catch the right thing, chaining so the original cause survives, and deciding when a failure should raise rather than return.
A caller of your library wants to handle one specific failure —
the photo format you cannot read — and carry on with the rest.
So they write except ValueError, because that is what your
code raises. It works, and it also swallows the ValueError
from deep inside the image library that means the file is
corrupt, and the one from int() on a malformed header.
They asked for one failure and caught three, because you never gave them a way to name the one they meant. By the end of this lesson you will design exceptions that callers can catch precisely, know when a failure should be an exception at all, and be able to preserve a cause instead of destroying it.
The foundations course covered catching. This one is about raising, and the shift is in who you are designing for.
A function's signature says what it takes and returns. The
exceptions it raises are equally part of the contract — they are
the other way it can finish — and unlike the signature, nothing
writes them down for you. If you raise ValueError for four
unrelated reasons, every caller who wants one of them gets all
four.
So the design question is: what does a caller need to distinguish? That determines your exception types, and nothing else does.
Give your package one base exception, then specific ones under it:
class PhotoToolsError(Exception):
"""Base for everything this package raises."""
class UnsupportedFormatError(PhotoToolsError):
"""The file is not an image format we can process."""
class CorruptImageError(PhotoToolsError):
"""The file claims to be an image but cannot be decoded."""
class MetadataError(PhotoToolsError):
"""The image is readable but its metadata is unusable."""One specific failure, with a specific response — skip it quietly and carry on.
Anything from this package. A generic "log it and move to the next file" is a reasonable answer to all of them.
Not yours. Let it travel — a KeyError from your own bug
should reach a traceback, not a warning line.
In code:
try:
process(path)
except UnsupportedFormatError:
skip_quietly(path) # one specific failure
except PhotoToolsError as error:
log_and_continue(path, error) # anything from this packageThe base class is what makes the middle option possible.
Without it, "catch anything my library raises" is not
expressible, and callers fall back to except Exception, which
catches your bugs as well as your failures.
Inheriting from a built-in is worth considering when the meaning genuinely matches:
class UnsupportedFormatError(PhotoToolsError, ValueError):
"""Also a ValueError, because the value is wrong."""Now existing code catching ValueError still works. Use it when
retrofitting exceptions into a library people already depend on;
skip it for new code, where the extra base mostly adds
ambiguity.
The message is read by someone with no context, often at an inconvenient hour.
Bad — a message that describes the rule.
if width <= 0:
raise ValueError("width must be positive")Good — a message that describes what happened.
if width <= 0:
raise ValueError(
f"width must be positive, got {width!r} for {path}"
)The first version tells the reader something they can infer from
the function name, and leaves them to find out which call, with
what value, on which file. That means adding a print, running it
again, and hoping it reproduces. The second answers all three
questions in the line they already have — and {width!r} rather
than {width} is deliberate, because it makes "0" visibly
different from 0, which is frequently the entire bug.
Three things worth including: the value you got, the value you expected, and enough identity to find the input again.
When you catch a low-level failure and raise your own, the original matters:
try:
image = decode(raw_bytes)
except OSError as error:
raise CorruptImageError(f"cannot decode {path}") from errorfrom error sets the cause, and the traceback shows both:
OSError: broken data stream when reading image file
The above exception was the direct cause of the following:
CorruptImageError: cannot decode photos/dawn.jpgYou get your meaningful message and the original detail.
Without from, Python prints "During handling of the above
exception, another exception occurred" — which still shows it,
but says something different and less accurate.
Deliberately hiding a cause is also possible, and occasionally right when the internal detail would be noise or leak something:
raise InvalidConfigError("config is not valid JSON") from NoneUse it rarely, and know that you are throwing away the information the next debugger will want.
Not every failure deserves an exception. The question is whether the situation is exceptional — outside what the caller should expect — or an ordinary outcome.
def find_photo(name: str) -> Photo | None: # ordinary
...
def load_photo(path: Path) -> Photo: # raises
...Not finding something you searched for is a normal result, and
None says so. Being unable to read a file you were told to
read is a failure, and an exception says so.
The distinction sharpens when you ask what the caller does. If
every caller would immediately write if result is None: raise,
then returning None just moved your raise into everyone else's
code — and the one caller who forgets gets an AttributeError
twenty lines later. Conversely, if most callers would wrap the
call in try/except and carry on, an exception is making a
routine outcome expensive.
Three practical rules:
Never return a sentinel that could be a real value. Getting
back -1, 0 or "" for failure means a caller who forgets to
check proceeds with plausible nonsense. None is safe because
it fails loudly on use; -1 is not, because it is a number.
Never return an error code that can be ignored. Python is not C. An ignored return value is silent; an ignored exception is impossible.
Do not use exceptions for control flow you expect every time. A parser that raises on every third line is using exceptions to mean "no", which is slower and harder to read than returning a result.
Where you raise matters as much as what.
def process_folder(folder: Path, limit: int) -> Report:
if not folder.is_dir():
raise NotADirectoryError(f"{folder} is not a directory")
if limit <= 0:
raise ValueError(f"limit must be positive, got {limit}")
... # now everything below can assumeChecking arguments at the top means the failure names the actual problem, at the moment nothing has happened yet. The alternative — discovering it halfway through — leaves you with partial work done and an error message about something several layers down.
This is the same instinct as the previous lesson's
__post_init__: validate once, at the boundary, so everything
inside can assume it holds.
An operation that fails partway can leave things in a state nobody planned for:
def write_report(path: Path, report: dict) -> None:
temporary = path.with_suffix(".tmp")
try:
temporary.write_text(json.dumps(report), encoding="utf-8")
temporary.replace(path) # atomic on the same volume
except Exception:
temporary.unlink(missing_ok=True)
raise # cleaned up, still failsWrite to a temporary file, then rename over the target. A rename on the same filesystem is atomic, so the destination is either the old complete file or the new complete file — never a half-written one. If anything fails, the temporary is removed and the exception continues upward.
That is except Exception used correctly, and the difference
from the anti-pattern is the bare raise on the last line: this
block cleans up, it does not decide the failure is unimportant.
DESIGN
exceptions are part of your interface
one base class per package, specifics beneath it
the question is: what must a caller distinguish?
class PhotoToolsError(Exception): ...
class UnsupportedFormatError(PhotoToolsError): ...
callers then choose their precision:
except UnsupportedFormatError one failure
except PhotoToolsError anything of ours
without a base class they fall back to except Exception
NAMING
name the situation, not the location
InvalidConfigError, not ConfigLoadError
end in Error
MESSAGES
what you got, what you expected, which input
f"width must be positive, got {width!r} for {path}"
!r makes "0" visibly different from 0
CAUSES
raise MyError(...) from error keeps both in the traceback
raise ... from None hides it - rarely right
bare `raise` re-raise, traceback intact
RAISE OR RETURN
X | None an ordinary "not found"
raise a real failure
ask what every caller would do with it
never a sentinel that could be real data (-1, 0, "")
never an ignorable error code
not for control flow you expect every time
WHERE
validate arguments at the top, before anything happens
clean up with try/except + bare raise, or finally
write to .tmp then rename - never a half-written fileYour failures now have names callers can aim at, messages that end an investigation rather than starting one, and causes that survive being wrapped. The judgement to keep is the raise-versus- return one: ask what every caller would do with the result, and if the answer is "immediately raise", you should have raised.
Next is Iterators, Generators, and Laziness, which changes
how data moves through your program. You have seen yield in
passing; that lesson makes it a tool — pipelines that process a
file larger than memory, and the specific bug that appears when
a generator is consumed twice.
Before you move on, take one module and give it a proper
exception hierarchy: a base class, two or three specifics, and
messages carrying the offending value. Then look at every place
it currently raises a bare ValueError and ask what a caller
would want to tell apart. That question is the whole of this
lesson.