This page is the stress test for the parent topic . The parent showed you the four blocks. Here we walk through every kind of situation the machine can land in — not just "an error happened", but which error, whether it was caught , what ran after , and the weird corner cases where two rules fight each other.
Before any code: remember the one picture. There are four rooms — try, except, else, finally — and Python's control (think of it as a glowing marble) rolls through some of them depending on what happens. Our whole job here is to trace the marble in every possible track.
Figure 1 — the four rooms and the marble's tracks.
Intuition The three questions we ask of every example
Did the try finish, or did it blow up part-way? (finished → the marble visits else; blew up → it looks for an except.)
If it blew up, did a listed except match? (Match via isinstance → handled. No match → the error keeps flying outward.)
What does finally do? — always the same answer: it runs, no matter what, right before the marble leaves the whole structure.
Definition What is a "ValueError object"? (a term we'll lean on)
When Python raises an error it does not raise a word — it builds an actual object in memory, an instance of a class. So ValueError("invalid literal...") is a little box holding (a) its type , ValueError, and (b) a message string . When we say "the marble carries a ValueError object," we mean this box travels outward until a handler catches it. The test isinstance(box, ValueError) asks: "Is this box's class ValueError, or a subclass of it?" — that is exactly the check each except clause performs (Rule 2). You can grab the box with except ValueError as e: and read e (its message) or type(e) (its class). Keep this picture: an exception is a labelled box, not just a name.
Figure 3 — an exception is a labelled box (type + message), and each except runs isinstance on it.
Every exception-handling situation is one of these cells . The worked examples below are each tagged with the cell(s) they cover, so by the end every row is filled.
Cell
Situation
Marble path
Covered by
A
try succeeds, nothing raised
try → else → finally
Ex 1
B
try raises, a specific except matches
try(partial) → except → finally
Ex 2
C
try raises, a parent class except catches a child
try(partial) → except → finally
Ex 3
D
Two except clauses, order decides the winner
first matching wins
Ex 3
E
try raises, no except matches → propagates
try(partial) → finally → outward
Ex 4
F
finally runs even when try does return
return "pauses", finally runs, then return completes
Ex 5
G
Degenerate / zero-ish input (empty string, division by zero)
depends on which error fires
Ex 6
H
Real-world word problem (parse a config line safely)
success + failure branch together
Ex 7
I
Exam twist: finally overrides a return value
finally's return wins
Ex 8
J
Bare except: swallows something it shouldn't (limiting/danger case)
catches even Ctrl-C
Ex 9
K
An exception is raised inside except or else
not re-caught by the same construct
Ex 10
L
finally itself raises
its exception replaces whatever was pending
Ex 11
We will now visit all twelve cells (A–L), each with its own fully worked trace.
Worked example Ex 1 — Cell A: the try just works
def read_age (s):
try :
n = int (s) # the only risky line
except ValueError :
return "not a number"
else :
return n * 2 # runs ONLY because int() succeeded
finally :
print ( "checked one input" )
Call read_age("21").
Forecast: what prints, and what is returned? Guess before reading on.
Trace it on the four-room map (Figure 1): the marble enters try, and since nothing raises it takes the blue "success" arrow down to else, then the always arrow into finally.
Run the try. int("21") → 21, no exception. Why this step? This is the happy path — statement 1 completes cleanly (Rule 1 never triggers).
Skip except. Why? By Rule 4's mirror image: except runs only if an exception was raised. None was.
Run else. 21 * 2 = 42, returned. Why here and not inside try? Rule 4 fires because the try was clean; keeping the doubling in else means if it somehow failed we would NOT want the ValueError handler to catch it.
Run finally before leaving. Prints checked one input, then the return of 42 completes (Rule 5).
Verify: Output is the line checked one input, return value 42. Path visited = try → else → finally. ✔ matches Cell A.
Worked example Ex 2 — Cell B: a specific except matches
def read_age (s):
try :
n = int (s)
except ValueError :
return "not a number"
else :
return n * 2
finally :
print ( "checked one input" )
Call read_age("oops").
Forecast: which room does the marble enter after try?
On Figure 1 the marble now takes the pink "error" arrow from try to except, then the always arrow to finally; the blue else arrow is never used.
Run the try. int("oops") raises a ValueError object (the labelled box from Figure 3). Why this step? "oops" has no integer meaning, so int signals failure the only way it can — by raising.
Abandon rest of try. Why? Rule 1: once a statement raises, following statements are skipped. (There were none after, but the principle holds.)
Scan except clauses top-to-bottom (Rule 2). isinstance(box, ValueError) is True → this first clause runs and scanning stops. Returns "not a number".
else is SKIPPED. Why? Rule 4: else runs iff the try finished with no exception — it didn't.
finally still runs (Rule 5). Prints checked one input before the return of "not a number" completes.
Verify: prints checked one input, returns "not a number". Path = try(partial) → except → finally. ✔ Cell B.
Worked example Ex 3 — Cells C & D: parent catches child, and order decides
The key fact (Rule 2): except ParentError catches the parent and all subclasses , and the first matching clause wins . KeyError is a subclass of LookupError (see The exception class hierarchy — BaseException, Exception, LookupError ).
Figure 2 explains why. It draws the family tree: Exception at the top, LookupError beneath it, and KeyError and IndexError as its two children. The upward arrows mean "is-a". Because KeyError sits under LookupError, the box raised by d["missing"] (pink node) answers yes to isinstance(box, LookupError) — so a LookupError handler grabs it. The yellow note on the left reminds you: child IS-A parent, so the parent's handler catches it. That is the whole reason a broad clause can shadow a narrow one.
Figure 2 — the class family tree: KeyError IS-A LookupError.
def lookup ():
d = {}
try :
return d[ "missing" ] # raises KeyError
except LookupError : # KeyError IS-A LookupError -> matches FIRST
return "lookup failed"
except KeyError :
return "never reached"
Call lookup().
Forecast: which of the two except lines wins?
Run the try. d["missing"] on an empty dict raises a KeyError object. Why? The key is absent, and dict access signals a missing key with KeyError.
Scan clauses top-to-bottom (Rule 2). First clause: is the box an instance of LookupError? Yes (KeyError is its child — see Figure 2). Why this matters: matching is by isinstance, not by exact-name equality.
First match wins; stop scanning (Rule 2). Returns "lookup failed". The second clause is dead code — Python never even tests it. Why? Only the first matching clause runs.
Mistake this exposes: if you want a special message for KeyError specifically, you must list the child first, parent last — otherwise the parent shadows it.
Verify: returns "lookup failed", never "never reached". ✔ Cells C (parent catches child) & D (order decides).
Worked example Ex 4 — Cell E: no clause matches → the error propagates
def parse (s):
try :
return int (s) # raises ValueError on bad input
except KeyError : # WRONG error listed on purpose
return "handled"
finally :
print ( "attempted parse" )
Call parse("nope").
Forecast: does this return a string, or crash?
Run the try. int("nope") raises a ValueError object. Why? Same as Ex 2 — not a valid integer.
Scan clauses (Rule 2). Only clause is except KeyError. Is the box an instance of KeyError? No. Why this fails: on Figure 2, ValueError is not anywhere under KeyError — they are unrelated branches; neither is the other's subclass.
No match → the exception is NOT handled (Rule 3). It will propagate outward to whoever called parse.
finally runs first anyway (Rule 5). Prints attempted parse. Why before propagating? Rule 5: finally always fires before control leaves the construct — including on the way out via an uncaught exception.
After finally, the ValueError keeps flying (Rule 3 again). If nobody up the stack catches it, the program ends with a traceback (see Debugging and tracebacks ).
Verify: prints attempted parse, then raises ValueError (does NOT return "handled"). ✔ Cell E.
Worked example Ex 5 — Cell F: finally runs even through a return
def f ():
try :
return "from try"
finally :
print ( "cleanup!" )
Forecast: does cleanup! print, given the return happens first?
try hits return "from try". Why this is subtle: return means "leave the function", which counts as "control leaving the construct" — exactly what Rule 5 watches for.
Because control is leaving, finally triggers (Rule 5). The return value "from try" is computed and set aside, then finally runs: prints cleanup!.
After finally, the pending return completes. Function returns "from try".
Verify: prints cleanup!, returns "from try". ✔ Cell F.
Worked example Ex 6 — Cell G: degenerate & zero inputs
One function, three degenerate inputs. int("") (empty string) and int(" ") (whitespace) both raise ValueError; dividing by zero raises ZeroDivisionError.
def reciprocal_of_int (s):
try :
n = int (s) # empty/whitespace -> ValueError
return 1 / n # n == 0 -> ZeroDivisionError
except ValueError :
return "bad number"
except ZeroDivisionError :
return "cannot divide by zero"
Trace three degenerate calls.
Forecast: guess the result of "", "0", and "4".
reciprocal_of_int(""). int("") raises ValueError (empty string has no integer value). By Rule 2 it matches the first clause → "bad number". Why: the second statement 1/n never runs — Rule 1 made the marble leave try at statement 1.
reciprocal_of_int("0"). int("0") → 0 (fine). Then 1 / 0 raises ZeroDivisionError. Scan (Rule 2): first clause ValueError? No. Second clause ZeroDivisionError? Yes → "cannot divide by zero". Why two clauses: the risky line changed — different statement, different error.
reciprocal_of_int("4"). int("4") → 4, 1/4 → 0.25, returned. No exception at all.
Verify: "" → "bad number"; "0" → "cannot divide by zero"; "4" → 0.25. ✔ Cell G (empty-string degenerate + division-by-zero limiting case).
Worked example Ex 7 — Cell H: real-world word problem
Problem. A config file has lines like timeout=30. Write a reader that returns the integer if the line is well-formed, None if the value isn't a number, and closes the file no matter what. Ties together success, failure, and cleanup.
def read_timeout (line):
try :
key, value = line.split( "=" ) # ValueError if no "="
n = int (value) # ValueError if value not integer
except ValueError :
return None
else :
return n
finally :
print ( "parsed a line" )
Trace "timeout=30", "timeout=fast", and "broken".
Forecast: which of the three lands in else, which in except?
"timeout=30". split("=") → ["timeout","30"], unpacks into two names. int("30") → 30. No exception → Rule 4 runs else, returns 30. finally prints (Rule 5).
"timeout=fast". split works, but int("fast") raises ValueError → Rule 2 sends it to except, returns None. else skipped (Rule 4). finally prints. Why else here: returning n outside try guarantees we only return a value that was actually parsed.
"broken". "broken".split("=") → ["broken"], one element. Unpacking one item into two names (key, value) raises ValueError too! Caught → None. Why the same clause: split failure and int failure both surface as ValueError, so one handler covers both — that's the degenerate no-equals-sign case.
Verify: "timeout=30" → 30; "timeout=fast" → None; "broken" → None. Each prints parsed a line. ✔ Cell H.
Worked example Ex 8 — Cell I: exam twist, finally's return overrides
A return inside finally replaces any return or exception that was pending. This is a trap — Rule 5 says finally "always runs before control leaves"; here it doesn't just run, it hijacks .
def tricky ():
try :
return "A"
finally :
return "B" # this return REPLACES the pending "A"
Forecast: "A" or "B"?
try sets up return of "A". Why this is pending: per Rule 5 the return value "A" is computed and held aside while finally is given its turn — control has not actually left yet.
finally runs and itself does return "B". Why this wins: a return inside finally is a new command to leave with a new value. Because finally gets the last word before control departs, its return overwrites the held-aside "A".
The held-aside "A" is discarded; "B" is what actually leaves. Why "A" is gone: only one value can be returned, and finally executed after "A" was staged, so "B" is the final decision.
Function returns "B". The "A" never reaches the caller.
return (or raise) from finally in real code — it silently eats results and even swallows exceptions. Use finally only for cleanup.
Verify: tricky() returns "B" (never "A"). ✔ Cell I.
Worked example Ex 9 — Cell J: the danger of a bare except
A bare except: catches everything , including signals that are not program errors — like KeyboardInterrupt when you press Ctrl-C. We simulate that danger by raising it manually.
def swallow_everything ():
try :
raise KeyboardInterrupt # what Ctrl-C sends
except : # BARE except — catches even this
return "caught it (bad!)"
Forecast: does the Ctrl-C signal escape, or get swallowed?
try raises KeyboardInterrupt. Why it matters: this is a subclass of BaseException, not of Exception — it's a control signal, not a bug.
Bare except: matches everything (Rule 2 with the widest possible type), including BaseException. So it swallows the interrupt → returns "caught it (bad!)". Why this is bad: the user pressed Ctrl-C to quit and your code quietly ignored them.
The fix: write except Exception as e: instead — KeyboardInterrupt is not under Exception (Figure 2's tree branches), so Rule 2 finds no match, and the interrupt flies past your handler and lets the user quit. See Logging vs printing errors for what to do with e.
Verify: the bare version returns "caught it (bad!)" (demonstrating the swallow). ✔ Cell J.
Worked example Ex 10 — Cell K: an exception raised INSIDE except (or else) is not re-caught here
A handler is not a new safety net for itself. If code inside except (or inside else) raises, the same try-construct does not catch it — that new error propagates outward like any uncaught error (Rule 3). This is why keeping handlers small matters.
Part 1 — error inside an except:
def handler_blows_up ():
try :
int ( "bad" ) # raises ValueError
except ValueError :
return 1 / 0 # NEW error (ZeroDivisionError) inside the handler
except ZeroDivisionError :
return "this does NOT rescue the handler"
Call handler_blows_up().
Forecast: does the second except catch the division-by-zero from the first handler?
try raises ValueError. Rule 2's first clause matches, so we enter that handler.
Inside the handler, 1/0 raises ZeroDivisionError. Why the sibling clause can't help: the except clauses only guard the try body (Rule 2 only scans on a raise from the try ), not each other. Once we are inside a handler, we have already left the guarded region.
The ZeroDivisionError propagates out of the whole construct (Rule 3). The second except ZeroDivisionError is never consulted. To protect handler code you would need a nested try inside it.
Part 2 — error inside an else:
def else_blows_up ():
try :
n = int ( "5" ) # succeeds -> try is clean
except ValueError :
return "caught in except"
else :
return 1 / 0 # raises ZeroDivisionError, in the ELSE block
Call else_blows_up().
Forecast: does the except ValueError catch the crash from else?
try succeeds. int("5") → 5, no exception, so by Rule 4 the marble goes to else.
else raises ZeroDivisionError. Why the except above is powerless: Rule 2 only scans except clauses on a raise from the try body . The else block is outside the protected region on purpose (that's the whole "keep try small" argument from the parent note) — so its error is not offered to except ValueError.
The ZeroDivisionError propagates outward (Rule 3), exactly like Part 1.
Verify: handler_blows_up() raises ZeroDivisionError; else_blows_up() also raises ZeroDivisionError (neither returns a string). ✔ Cell K (error in except and error in else).
Worked example Ex 11 — Cell L: what if finally ITSELF raises?
finally is code too, so it can fail. If finally raises while an earlier exception was already travelling outward, the new exception from finally replaces the old one — the original is lost from the top of the traceback (Python keeps it as the "during handling of the above" context, but the one that leaves the construct is finally's).
def finally_blows_up ():
try :
raise ValueError ( "original problem" )
finally :
raise RuntimeError ( "cleanup itself failed" )
Call finally_blows_up().
Forecast: which error escapes — the original ValueError, or the RuntimeError from finally?
try raises ValueError("original problem"). By Rule 3 (no except) it starts travelling outward; but Rule 5 says finally runs first, so it heads for finally.
finally runs — and itself raises RuntimeError. Why this is dangerous: finally is supposed to tidy up , but a failing cleanup now becomes the story. A new box is created and starts leaving.
The RuntimeError wins; the ValueError is displaced. What escapes the function is RuntimeError("cleanup itself failed"). The original is still visible in the chained traceback context, but the active exception is the new one.
Verify: finally_blows_up() raises RuntimeError, not ValueError. ✔ Cell L.
Definition Notation note — the
::: in recall lines
In the collapsible recall block below, each line has the shape Prompt ::: Answer. The ::: is our vault's reveal separator : everything before it is the question you try to answer from memory, everything after it is the answer that stays hidden until you check. Read only the left side first, guess, then reveal.
Recall Every cell, one line each
Cell A — try succeeds ::: path is try → else → finally, and except is skipped (Rule 4).
Cell B — specific except matches ::: try(partial) → except → finally, else skipped (Rules 1, 2, 4, 5).
Cell C — parent catches child ::: except LookupError catches KeyError via isinstance (Rule 2).
Cell D — order decides ::: first matching clause wins and scanning stops, so child before parent (Rule 2).
Cell E — no match ::: finally runs, then the exception propagates outward (Rules 3, 5).
Cell F — finally through return ::: return is set aside, finally runs, then return completes (Rule 5).
Cell G — degenerate input ::: empty string → ValueError; divide by zero → ZeroDivisionError.
Cell H — real-world config parse ::: success → else returns n; no "=" or bad int → except returns None; finally always prints.
Cell I — return in finally ::: it overrides the pending value/exception (never do this).
Cell J — bare except ::: swallows even KeyboardInterrupt; use except Exception instead.
Cell K — error inside except/else ::: not re-caught by the same construct; it propagates outward (Rule 3).
Cell L — finally itself raises ::: the finally's exception replaces the pending one; the original is displaced.
The exception class hierarchy — BaseException, Exception, LookupError — why KeyError is a LookupError and why KeyboardInterrupt is not an Exception.
Raising exceptions — raise and custom exception classes — Ex 9, 10 and 11 use raise to build the corner cases.
Context managers — with statement and __enter__/__exit__ — the clean replacement for try/finally, and safer when cleanup can fail (Cell L).
File handling in Python — Ex 7's real-world flavour.
Debugging and tracebacks — what Cells E, K and L's propagation eventually prints.
Logging vs printing errors — what to do with the caught e.