1.3.6 · D2Python Intermediate

Visual walkthrough — Raising exceptions — raise, custom exception classes

2,843 words13 min readBack to topic

Before any code, two words we will use constantly:


Step 1 — The normal world: values flow back to the caller

WHAT. First we draw the ordinary case, so the abnormal case has something to break.

WHY. You can't appreciate "raise interrupts flow" until you've seen unbroken flow. When nothing goes wrong, a computes a value and hands it back to b, which hands it to c.

PICTURE. Three stacked plates. A blue arrow (the return value) travels from the top plate a back to the caller c.

The rest of this page is the story of that second verb.


Step 2 — The moment of raise: an object is born

WHAT. We zoom into the exact instant raise ValueError("boom") executes inside a.

WHY. The parent note said "build the exception object, attach a traceback." Those are two distinct events and we must see both — because a common bug (return ValueError(...)) does the first without the second.

PICTURE. Inside plate a, a small box labelled ValueError("boom") is created (the object / marble), and a paper tag labelled traceback gets pinned to it recording "born in a".


Step 3 — Abandon the current line, look for a local except

WHAT. The instant the object exists, Python stops running the rest of a. It first checks: is this raise sitting inside a try block in a itself?

WHY. The search always starts closest to home. A try in the very function that raised gets first refusal — because that's the innermost except "around" the failing line.

PICTURE. In plate a, the ordinary lines after raise are crossed out (never run). A magnifying glass scans a for a try. In our example a has none, so the search must leave a.

def a():
    raise ValueError("boom")   # marble starts here
    print("never runs")        # ← crossed out: ordinary line after raise is abandoned

Step 4 — Cleanup on the way out: finally runs before a frame is popped

WHAT. With no handler in a, Python is about to discard a's frame. But before it does, any finally block attached to the try the marble is escaping gets executed.

WHY. finally means "run this no matter what — success, exception, or unwinding." So even during propagation, cleanup (closing files, releasing locks) is guaranteed. Only then is the frame popped and the marble handed to the caller b (which has no try, so it too is popped).

PICTURE. Plate a is being lifted off, but a yellow finally strip fires first; then the marble continues down to b, which is also popped. The traceback tag from Step 2 keeps growing.

def a():
    try:
        raise ValueError("boom")
    finally:
        print("cleanup a")   # ← STILL runs, even though nothing here catches the marble
def b():
    a()                      # no try → exception passes straight through b
def c():
    try:
        b()                  # ← the try that WILL catch it lives here
    except ValueError as e:
        print("handled:", e)

Output order: cleanup a prints first (unwinding a), then handled: boom (caught in c). b is completely innocent — the marble rolls through it precisely because it has no try. See Try-Except-Finally for the full finally semantics.


Step 5 — A matching except catches it

WHAT. The marble reaches c, whose try block wraps the call to b. Python asks: does the raised object's type match the except?

WHY. Matching is by type (or subclass). ValueError matches except ValueError, so the marble stops. Control jumps into the except body; e is bound to our object from Step 2 — message and traceback intact.

PICTURE. Plate c has a blue "net" (the except) stretched across it. The rolling marble lands in the net; an arrow shows e now pointing at the same ValueError("boom") object born back in a.

Result: handled: boom.


Step 6 — Corner case: bare raise — re-throw the same marble

WHAT. Inside an except block you can write raise with no argument at all.

WHY. Sometimes a handler wants to peek at the marble — log it, count it — but is not qualified to fix it, so it must let the very same marble keep travelling. A bare raise does exactly that: it re-throws the exception currently being handled, with its original traceback preserved (it does not start a fresh journey from here).

PICTURE. In c's net, the marble is briefly held, a note is written ("logged"), then the same marble (same colour, same traceback tag) is tossed back out to continue upward.

def c():
    try:
        b()
    except ValueError:
        log("saw a ValueError")   # peek / record
        raise                     # ← bare raise: re-throw the SAME marble, keep its traceback

Step 7 — Corner case: raise B from A — chaining two marbles

WHAT. A handler can catch one marble A and throw a different, higher-level marble B, explicitly linking them: raise B from A.

WHY. Low-level errors (ValueError from int(...)) mean little to a caller who thinks in domain terms (ConfigError). Chaining lets you translate the error without hiding the original — the traceback prints both, joined by "The above exception was the direct cause of the following exception."

PICTURE. Marble A (pink) is caught; a new marble B (blue) is created and launched, with a chain link labelled __cause__ tying it back to A.

try:
    int(user_text)                       # may drop a ValueError marble = A
except ValueError as A:
    raise ConfigError("bad port") from A # B, linked: B.__cause__ = A

Seeing __context__ without from. If you forget from A but still raise inside the except, Python quietly records the old marble anyway — as __context__, not __cause__:

try:
    int("x")                       # ValueError = A
except ValueError:
    raise ConfigError("bad port")  # NO 'from' — but Python still links A
# ConfigError.__context__  is the ValueError  (implicit)
# ConfigError.__cause__    is None            (you didn't ask for a cause)

The only practical difference: with from A the report reads "direct cause" (you're saying "this new error is because of that one"); without it the report reads "during handling... another exception occurred" (Python's honest "you were mid-handling when a second thing went wrong"). Both keep the original visible; from states intent.


Step 8 — Edge case: nobody catches it

WHAT. Suppose c had no try either. Then the marble rolls off the bottom plate.

WHY. We must cover the case where the search runs out of frames. When there is no frame left below, the exception reaches the interpreter's top level. Python then prints the traceback (the tag that grew as it unwound) and exits with a non-zero status.

PICTURE. All plates popped. The marble falls past the last plate into a "top level" tray, which prints the full path a → b → c and the message.

Traceback (most recent call last):
  File ..., in c
  File ..., in b
  File ..., in a
ValueError: boom

Step 9 — A richer marble: a custom exception carrying data

WHAT. Now we raise not a bare built-in but a custom exception that carries facts. This is the parent's InsufficientFundsError.

WHY. A named type lets c catch this exact situation; the attached data lets the handler do something precise (e.g. show the shortfall). The type is the address; the data is the letter inside.

Before the code, one word that word "inherit from Exception" quietly relies on:

PICTURE. Same journey, but the marble now has three labelled compartments — balance, requested, shortfall — and its .args message set by super().__init__.

class InsufficientFundsError(Exception):   # inherit Exception, NOT BaseException
    def __init__(self, balance, requested):
        self.balance   = balance                    # data compartment 1
        self.requested = requested                  # data compartment 2
        self.shortfall = requested - balance        # precomputed fact
        super().__init__(                           # ← fills .args so str(e) prints
            f"Tried {requested}, only {balance} available."
        )
 
try:
    raise InsufficientFundsError(balance=50, requested=80)
except InsufficientFundsError as e:
    print(e)             # Tried 80, only 50 available.
    print(e.shortfall)   # 30

Here shortfall = requested - balance = 80 - 50 = 30, exactly what the handler prints.


The one-picture summary

WHAT. One diagram compressing all nine steps.

Reading the summary picture, top to bottom. Three plates sit stacked — a (newest, top), b (middle), c (oldest, bottom). A pink marble is dropped at plate a: that is raise / "Born." Immediately a yellow strip fires on a — the finally cleanup that always runs before a frame leaves. Then plate a fades and lifts off ("Pop"), and so does b, which had no net. The marble rolls down to plate c, where a blue net (except) is stretched — "Net." At that net three outcomes are labelled: handle it, bare-raise the same marble onward, or raise ... from a new linked marble. A dashed arrow leaves the bottom to a yellow tray labelled "no net anywhere → crash + traceback" — the Step 8 escape. So the single picture says: Born → Finally → Pop → Net, and at the net one of {handle, bare-raise, from}, else crash.

Recall Feynman: retell the whole walkthrough in plain words

Picture a stack of plates: the bottom plate is the function c that started everything, the top plate is the deepest function a. Normally, a cooks a value and passes it back down to c. But one day a hits a wall it can't handle, so instead of a value it makes a little complaint-marble (the exception object) and drops it. First, any finally cleanup on the way out still runs — Python always tidies up before throwing a plate away. Then the marble checks: is there a safety net (try) here? No — plate a is discarded, marble rolls to b, no net, discard, down to c. Plate c does have a net (except ValueError). It can just handle it — or it can peek, jot a note, and toss the same marble onward with a bare raise — or it can catch this marble and throw a fancier one linked back to it with from, so the crash report shows both. If no plate ever nets it, the marble rolls off the bottom and the program crashes, printing the marble's diary (the traceback). A fancy marble (a custom exception) can carry labelled pockets — balance, requested, shortfall — so the catcher knows not just that something broke, but exactly what and by how much. And every ordinary error-marble lives under Exception, kept separate from the "please quit / Ctrl-C" signals under BaseException, so a plain net doesn't accidentally grab those.


Connections

  • Parent: Raising exceptions — the concepts these pictures animate
  • Try-Except-Finally — the "net" of Step 5 and the finally cleanup of Step 4, in full
  • Exception Hierarchy & BaseException — why the marble must be a subclass of Exception
  • Classes and __init__ — building the data-carrying marble in Step 9
  • Logging in Python — peek at the marble mid-flight (bare re-raise, Step 6)
  • assert statement — a lighter way to drop a marble