Your nightly job imports 500 orders, and one of them has a date Python cannot
parse. Should the job stop, skip that order, or pass the error back to the
code that started it? That depends on where you put the try.
Before you write a handler, decide what it will do with the error. It can
recover, translate the error for its caller, clean up, or report that one
piece of work failed. If it does none of these, leave the try out and let
the exception go up to a caller that can.
Keep the try block small
This function reads a value from a cache. When the key is missing, it computes the value and stores it:
from collections.abc import Callable
def cached_value(
cache: dict[str, int], key: str, compute: Callable[[str], int]
) -> int:
try:
return cache[key]
except KeyError:
value = compute(key)
cache[key] = value
return value
cache: dict[str, int] = {}
assert cached_value(cache, "hello", len) == 5
assert cache == {"hello": 5}
Only the lookup cache[key] is inside the try. compute runs in the
handler, outside the try block, so a KeyError from a bug in compute
escapes and you see it. If compute were inside the try, the handler
would catch that KeyError too and mistake the bug for a missing key.
A handler cannot undo work. If the try block saved two of three rows before
it failed, those two rows stay saved. Return success only when the work is
done.
Let Ctrl-C and sys.exit() through
A few exceptions mean "stop", not "this task failed". They inherit from
BaseException but not from Exception, so except Exception lets them pass:
| Exception | When Python raises it |
|---|---|
KeyboardInterrupt |
You press Ctrl-C. |
SystemExit |
Your code calls sys.exit(). |
GeneratorExit |
A generator is closed before it finishes. |
A bare except: catches all three, and so does except BaseException. In a
loop that runs tasks, Ctrl-C then skips to the next task instead of stopping
the program. A task that calls sys.exit() does not exit either.
Inside a generator, a broad handler causes a different bug. When code closes
the generator early, Python raises GeneratorExit at the paused yield. A
broad handler catches it, and if the generator then yields again, Python
reports RuntimeError: generator ignored GeneratorExit.
To clean up however a block ends, use finally or a with block. They run
for Ctrl-C too, and they let the exception continue afterwards. If you do
catch BaseException, end the handler with a bare raise.
Catch broadly where one piece of work ends
A boundary is the place where one piece of work ends and its caller needs a
clear answer, success or failure. A batch runner is a boundary. Its tasks are
independent, so it can catch Exception from one task, record it, and start
the next. A web framework is another boundary. It turns an unhandled exception
into an error response, so your view code often needs no catch-all of its own.
except Exception also catches your own bugs, such as a NameError from a
typo. At a boundary, log the full traceback with logger.exception(...), so
the bug stays visible. Log it once, at the boundary, and not again at every
layer on the way up.
The next exercise, Fix a Batch Runner That Catches Too Much, applies this
rule to a run_all function.
Return from the try block, not from finally
A return inside finally throws away an exception that is on its way out:
def hidden_failure() -> str:
try:
raise ValueError("invalid record")
finally:
return ""
assert hidden_failure() == ""
The assertion passes, and the ValueError is gone. A break or continue
that leaves a finally block does the same. Use finally for cleanup only,
and return from the try block or after the whole statement.
Check your understanding
Should a request fail if the metrics call that runs after saving the order raises ConnectionError?
Show answer
Hide answer
Usually not. You already saved the order, and the metric is extra. Catch
ConnectionError around the metrics call only, log it, and return success.
Keep that try small, so a bug elsewhere in the handler still fails the
request and shows its traceback.
Going further
asyncio has its own cancellation exception, asyncio.CancelledError, and it
is also outside Exception. The concurrency track, Concurrency with Threads and asyncio, teaches it.
Python 3.14 and later print a SyntaxWarning for a return, break or
continue that leaves a finally block.
The linter Ruff can find broad handlers in your own code. Rule E722 flags
a bare except:, and rule BLE001 flags an except Exception that neither
re-raises nor logs the traceback.