1.3.5 · D2Python Intermediate

Visual walkthrough — Exception handling — try, except (specific), else, finally

2,384 words11 min readBack to topic

We build a running example and watch it three ways: no error, caught error, uncaught error. Nothing is assumed — we define every word the first time it appears.


Step 0 — What is an "exception", really?

Figure — Exception handling — try, except (specific), else, finally

Look at the figure: the top cyan pipe is the normal channel; the amber drop-down arrow is the moment of raise. The whole rest of the walkthrough is: where does that amber arrow land?


Step 1 — The stage: four blocks stacked as chutes

WHAT. We lay out the four blocks — try, except, else, finally — as four horizontal shelves, in the exact order Python reads them.

WHY. Control flow only ever moves between these shelves. If we can draw the shelves once and mark the legal jumps between them, every future example is just "trace the arrows".

PICTURE. Each shelf holds the statements from the parent's skeleton:

Figure — Exception handling — try, except (specific), else, finally

In the figure, notice finally is drawn as a turnstile at the bottom: no matter which shelf you were standing on, you must pass through it to leave.


Step 2 — The clean run (no exception)

WHAT. Run try all the way to the end with nothing going wrong, then follow where the flow goes next.

WHY. This is the baseline. Before we study failure we must know the shape of success — otherwise we can't tell what failure changed.

PICTURE. The flow stays in the top pipe through every try statement, then hops to else, then down through finally.

Concrete code:

try:
    n = int("42")        # S1: succeeds, n = 42
except ValueError:
    print("bad")         # SKIPPED — no error to catch
else:
    print(n)             # runs: prints 42
finally:
    print("done")        # runs: prints done

Output order: 42, then done.

Figure — Exception handling — try, except (specific), else, finally

The green path in the figure never dips into the error channel. except is greyed out — unreached.


Step 3 — The caught run: the flow drops into the error channel

WHAT. Now statement S1 raises. Watch the flow abandon the rest of try, drop into the error channel, and get stopped by a matching except.

WHY. This is the whole point of exception handling: a mid-block failure must not keep running the following statements (rule 1 of the parent). We visualise exactly which statements are skipped.

try:
    n = int("oops")      # S1: RAISES ValueError here
    print("unreachable") # S2: SKIPPED — never runs
except ValueError:       # filter opens: ValueError IS-A ValueError
    print("bad input")   # runs
else:
    print(n)             # SKIPPED — try was NOT clean
finally:
    print("done")        # runs

Output: bad input, then done.

Figure — Exception handling — try, except (specific), else, finally

In the figure the amber arrow drops at S1; S2 and else are crossed out; except glows because its filter matched.


Step 4 — Why else, not "end of try"? (the small-region argument)

WHAT. Move the success line print(n) inside try versus keeping it in else, and compare what an accidental error does.

WHY. The parent claimed else keeps the guarded region "as small as possible". Let's see the bug that else prevents.

PICTURE. Two side-by-side chutes. On the left, the success line lives in try, so a different error it raises falls into the same except — a mask. On the right, else sits outside the guarded region, so its errors escape past except.

# LEFT — dangerous: success line inside try
try:
    n = int("42")
    print(undefined_var)   # raises NameError — a BUG
except ValueError:
    print("bad")           # This except is for VALUE errors...
# ...but NameError is not caught here, it propagates — good luck if you'd
# written a broader except: it would SILENTLY swallow your typo.
# RIGHT — safe: success line in else
try:
    n = int("42")          # ONLY the risky parse is guarded
except ValueError:
    print("bad")
else:
    print(undefined_var)   # NameError raised OUTSIDE the guard → not caught → visible
Figure — Exception handling — try, except (specific), else, finally

Notice in the figure: the amber "bug" error on the right shoots straight past the except filter (it's a NameError, not a ValueError), because it was raised outside the guarded box. On the left it's trapped inside the box — dangerous.


Step 5 — Ordering except clauses: child before parent

WHAT. Two except clauses, one for a parent class (LookupError) and one for its child (KeyError). See which one "grabs" the error.

WHY. The filter test is isinstance, which is True for subclasses too. So a broad filter placed first will shadow a narrow one below it — the narrow one becomes dead code.

PICTURE. The error object falls past the clauses top-to-bottom; the first filter that accepts it stops it. A wide net placed high catches everything.

try:
    d = {}
    d["k"]                 # raises KeyError
except LookupError:        # KeyError IS-A LookupError → filter accepts FIRST
    print("lookup failed") # runs
except KeyError:
    print("never reached") # DEAD CODE — parent above already caught it
Figure — Exception handling — try, except (specific), else, finally

See The exception class hierarchy — BaseException, Exception, LookupError for the full family tree. In the figure the wide amber net (LookupError) sits above the narrow net (KeyError) and catches the falling ball first.


Step 6 — finally is a turnstile: it runs even on return

WHAT. Put a return inside try. Show that finally still runs — before the function actually hands back its value.

WHY. People assume return means "leave immediately". But rule 5 says every exit passes through finally. This is the degenerate case that surprises everyone.

PICTURE. The return value is "packed into a box" and held at the turnstile; finally runs; then the box is released.

def f():
    try:
        return "from try"    # value packed, but not yet delivered
    finally:
        print("cleanup!")    # turnstile: runs BEFORE the value leaves
# Calling f():  prints "cleanup!"  then returns "from try"
Figure — Exception handling — try, except (specific), else, finally

The amber box labelled "from try" waits at the turnstile in the figure while cleanup! prints, then slides out.


Step 7 — The uncaught run: propagation and crash

WHAT. Raise an error that no except filter accepts. Trace it out of this construct, up the call stack, to a crash.

WHY. We promised to cover every case (rule 4). The "nobody catches it" case is where a traceback comes from — and finally still runs on the way out.

PICTURE. The error ball rolls past all filters, drops through finally (which still fires), then keeps falling out of the function into the caller — and if the caller has no handler either, off the bottom = crash.

try:
    x = 1 / 0                # raises ZeroDivisionError
except ValueError:           # filter REJECTS: not a ValueError
    print("bad")
finally:
    print("cleanup")         # STILL runs on the way out
# then: ZeroDivisionError propagates → traceback → crash (if never caught)

Output: cleanup, then the program crashes with a ZeroDivisionError traceback.

Figure — Exception handling — try, except (specific), else, finally

In the figure the ball misses every filter, still touches the finally turnstile (it glows), then falls off the bottom into the caller — see Debugging and tracebacks for reading the crash report that results.


The one-picture summary

WHAT. All three journeys — clean, caught, uncaught — on one map, sharing the same finally turnstile.

Figure — Exception handling — try, except (specific), else, finally
Recall Feynman retelling — the whole walkthrough in plain words

Picture your code as a top pipe (normal work) and a bottom pipe (error alarm). Statements run calmly along the top of the try shelf.

If nothing breaks, the flow slides off the end of try, hops onto the else shelf (the "all clear" one), and then goes through the finally turnstile on its way out. The except filter never even wakes up.

If something breaks, the flow drops into the bottom pipe the instant the error is born. Everything below that point in try is skipped. The error ball rolls past the except filters top-to-bottom; the first filter that says "yes, I accept this type" grabs it and runs its handler. Because filters accept subclasses too, you always list the specific ones first, or a wide parent net catches everything and your specific handler becomes dead code. After the handler, the flow exits through the finally turnstile.

If no filter accepts the ball, it keeps falling — but it still touches the finally turnstile on the way (cleanup is guaranteed even in a crash), then drops out of this function into whoever called it. If nobody up the stack catches it, it falls off the bottom of the whole program and you get a traceback.

And the turnstile is stubborn: even a return inside try has to pass through finally before its value is delivered. Everything leaves through finally — that's the one shelf on every path.


Connections

  • Parent: Hinglish version of the parent
  • The exception class hierarchy — BaseException, Exception, LookupError — why isinstance matching orders your clauses.
  • Raising exceptions — raise and custom exception classes — how the error object in Step 0 is born.
  • Context managers — with statement and __enter__/__exit__ — a cleaner replacement for the try/finally turnstile.
  • File handling in Python — the classic place finally (or with) closes resources.
  • Debugging and tracebacks — reading the crash from Step 7.
  • Logging vs printing errors — what to do inside the except handler.