codejump

AcademyPython

Exceptions and Error Handling in Python

What each clause of try/except/else/finally actually guarantees, how raise from chains a new exception to its cause, and why a bare except is almost always wrong.

3 min readUpdated

3 exercises

Waiting at the end of this page.

Four clauses, four different guarantees

python
try:
    value = risky_call()
except ValueError as e:
    handle(e)
else:
    use(value)
finally:
    cleanup()

except runs only if a matching exception was raised in try. else runs only if try completed with no exception at all — not "no exception this branch handles," but none whatsoever. finally runs unconditionally: exception or not, handled or not, even if try or except returns early. The else clause is the one people skip, reaching instead for putting use(value) at the end of try — but that puts code that was never expected to fail inside the block that catches failures, which can silently swallow a bug in use itself if it happens to raise a ValueError too.

Catch the specific exception, not everything

python
try:
    config = json.loads(raw)
except:
    config = {}

A bare except — no exception type at all — catches KeyboardInterrupt and SystemExit along with everything else, which means Ctrl-C stops working as an interrupt and starts silently falling into the fallback branch instead. It also hides the real bug: if raw is fine but json was never imported, the resulting NameError is swallowed by the same clause meant only for malformed JSON. The fix is naming the exception the code actually expects:

python
try:
    config = json.loads(raw)
except json.JSONDecodeError:
    config = {}

Anything else still propagates, which is what should happen with a bug nobody anticipated.

Chaining exceptions with raise from

python
try:
    parsed = int(user_input)
except ValueError as e:
    raise InvalidInputError(f'{user_input!r} is not a number') from e

Without from e, the traceback still shows both exceptions when one is raised inside an except block — Python calls this implicit chaining, shown as "During handling of the above exception, another exception occurred." from e makes the relationship explicit instead of incidental: it sets __cause__ on the new exception, and the traceback reads "The above exception was the direct cause of the following exception," which tells whoever reads the log that the second error is not a surprise — it is the deliberate translation of the first one into a more meaningful type. raise ... from None suppresses the chain entirely, for the rarer case where the original exception is implementation detail that would only confuse the reader.

Writing a custom exception

python
class InsufficientFundsError(Exception):
    def __init__(self, balance, amount):
        self.balance = balance
        self.amount = amount
        super().__init__(f'cannot withdraw {amount}, balance is {balance}')

A custom exception is an ordinary class inheriting from Exception (or a more specific built-in, when the failure really is a ValueError or TypeError with extra context). Attaching structured data — balance and amount here — as attributes rather than only formatting them into the message string lets calling code inspect exactly what went wrong programmatically, instead of parsing an error string to recover the numbers.

Exception groups

python
try:
    run_all_tasks()
except* ValueError as eg:
    for e in eg.exceptions:
        log(e)

except*, added in Python 3.11, handles the case a single try block used to have no clean answer for: several independent failures from concurrent work, all needing to surface at once rather than only the first one raised. An ExceptionGroup bundles them, and except* matches and unwraps the ones of a given type while leaving the rest to propagate in their own group.

Now practice it

1 challenge · 2 quizzes

Reading this page is the cheap half. These are the exercises that make you use it.