Skip to content
← Back to explainers

Python explainer

Where to Catch an Exception

About 4 min to read. Allow extra time to work through examples.

Put each try block around the call you can recover from, let Ctrl-C and sys.exit() through, and catch broadly only where one piece of work ends.

This article uses Python. You can make it your practice mode to browse related content across the site.

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

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.

step 8 of 20

Error Handling with Exceptions, Retries and Warnings

Up next Fix a Batch Runner That Catches Too Much