1.3.5 · D4Python Intermediate

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

3,696 words17 min readBack to topic

Before we begin, one reminder of the control-flow rules we are testing (from the parent note), because every answer below traces them:


Level 1 — Recognition

(Can you read code and predict which block runs?)

L1.1

What does this print?

try:
    print("A")
    x = 10 / 2
    print("B")
except ZeroDivisionError:
    print("C")
else:
    print("D")
finally:
    print("E")
Recall Solution

Trace the rules. try runs top to bottom: prints A, computes 10/2 = 5.0 (no error), prints B. The try finished with no exception, so by rule 4 the else runs → prints D. The except does not run (nothing was raised). By rule 5 finally always runs → prints E. Output:

A
B
D
E

L1.2

Same skeleton, but now the division fails:

try:
    print("A")
    x = 10 / 0
    print("B")
except ZeroDivisionError:
    print("C")
else:
    print("D")
finally:
    print("E")
Recall Solution

try prints A, then 10 / 0 raises ZeroDivisionError. By rule 1, everything after it in try is skipped — so B is never printed. By rule 2, the except ZeroDivisionError matches → prints C. Because an exception occurred, the else is skipped (rule 4). finally always runs → prints E. Output:

A
C
E

L1.3

True or false: in except (ValueError, KeyError):, both errors run the same handler code.

Recall Solution

True. A tuple of exception types means "catch any of these with this one block". It is exactly equivalent to isinstance(error, (ValueError, KeyError)). It does not mean "catch a ValueError and a KeyError at once" (only one exception is ever in flight).


Level 2 — Application

(Write small handlers that behave correctly.)

L2.1

Write safe_divide(a, b) that returns a / b, but returns the string "undefined" if b is 0. Guard only the risky division.

Recall Solution
def safe_divide(a, b):
    try:
        result = a / b        # the only risky line
    except ZeroDivisionError:
        return "undefined"
    else:
        return result         # return only when the division succeeded

Why else? So the return result sits outside the protected region — if returning ever misbehaved it would not be swallowed by our except. Keeps the guarded area minimal. Checks: safe_divide(6, 2) == 3.0, safe_divide(5, 0) == "undefined".

L2.2

Write get_item(d, key) returning d[key], or -1 if the key is missing. Catch the narrowest correct exception.

Recall Solution

A missing dict key raises KeyError, so catch exactly that (not a bare except, not Exception).

def get_item(d, key):
    try:
        return d[key]
    except KeyError:
        return -1

Why this works: d[key] is the only line that can raise on a missing key, and it raises precisely a KeyError — so except KeyError is the narrowest net that still catches the failure we care about. Any other error (say a typo that produced a NameError) is not a KeyError, so it propagates and surfaces the real bug instead of being masked as -1. Checks: get_item({"a": 5}, "a") == 5, get_item({"a": 5}, "z") == -1.

L2.3

Write parse_and_double(s) that returns 2 * int(s), or 0 if s is not a valid integer string.

Recall Solution

int("oops") raises ValueError. Guard the parse, return double on success.

def parse_and_double(s):
    try:
        n = int(s)
    except ValueError:
        return 0
    else:
        return 2 * n

Why this works: only int(s) can fail on bad input, and it fails with a ValueError, so that is the exact clause type to catch. The doubling 2 * n is not risky, so it belongs in else — this keeps the guarded region to the single parse line, meaning an unrelated bug in the arithmetic could never be swallowed as 0. A leading - like "-4" is a valid integer to int, so it parses fine and doubles to -8. Checks: parse_and_double("21") == 42, parse_and_double("hi") == 0, parse_and_double("-4") == -8.


Level 3 — Analysis

(Explain and predict subtle ordering / interaction behaviour.)

L3.1

Recall the class hierarchy LookupError is the parent of both KeyError and IndexError. Predict the output:

try:
    [1, 2, 3][99]           # raises IndexError
except LookupError:
    print("lookup")
except IndexError:
    print("index")
Recall Solution

[1,2,3][99] raises IndexError. By rule 2, Python scans clauses top to bottom and runs the first match. Here the first clause's ClauseType is LookupError, and isinstance(IndexError instance, LookupError) is True (an IndexError is a LookupError), so the first clause matches → prints lookup. The second clause is unreachable — the parent shadowed the child. Output: lookup.

The figure below shows the same idea as two "nets". What the picture shows (in words, for non-visual readers): on the left, the red IndexError falls downward; the first clause except LookupError is drawn as a wide blue net placed above the second net, so the falling error is caught there immediately (labelled "MATCH: isinstance(IndexError, LookupError) = True"). The lower gray net except IndexError is drawn dashed and labelled "unreachable — shadowed": the error never reaches it. On the right is the fix — the specific orange net except IndexError is now on top and catches the error, while except LookupError sits below it and stays reachable for other lookup errors like KeyError. The moral: whichever net is higher (listed first) catches first.

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

L3.2

What is the output, and why?

def f():
    try:
        return "try"
    except Exception:
        return "except"
    finally:
        return "finally"
 
print(f())
Recall Solution

The try executes return "try" — control is about to leave, but by rule 5 finally must run before control leaves. The finally block itself does return "finally", which overrides the pending return. A return in finally wins over any earlier return. Output: finally. (This is a known footgun — return inside finally silently discards the try/except return value and any in-flight exception.)

L3.3

Reorder the clauses in L3.1 so both messages are reachable in principle. Which order, and what does the corrected snippet print for the same IndexError?

Recall Solution

Put the child first, parent last (rule of thumb from the parent note):

try:
    [1, 2, 3][99]
except IndexError:
    print("index")
except LookupError:
    print("lookup")

Now IndexError matches the first (specific) clause → prints index. A different LookupError such as a KeyError would fall through to the second clause. Both are now reachable. Output: index.


Level 4 — Synthesis

(Combine all four blocks into one correct, minimal construct.)

L4.1

You are given open(path) (may raise FileNotFoundError), f.read() (may raise UnicodeDecodeError), and you must always record the attempt. Write a function read_report(path) that:

  • returns the file text on success,
  • returns "missing" if the file isn't there,
  • returns "corrupt" if decoding fails,
  • closes the file if it was opened, and always prints "attempted".

Keep each risky operation in the smallest possible guarded region.

Recall Solution

The subtlety: open and read raise different errors, and only read needs the file to be closed afterward. We nest so each guard is minimal, and use finally for the universal log.

def read_report(path):
    try:
        f = open(path)                 # risky #1
    except FileNotFoundError:
        return "missing"
    else:
        try:
            return f.read()            # risky #2, only if open succeeded
        except UnicodeDecodeError:
            return "corrupt"
        finally:
            f.close()                  # close iff we actually opened it
    finally:
        print("attempted")             # ALWAYS, opened or not

Why the structure?

  • open alone sits in the outer try — if it fails we never got a file, so there is nothing to close.
  • read sits in the else (open succeeded) with its own inner try/finally so the file is closed no matter how the read ends.
  • The outer finally prints "attempted" for every path, including the FileNotFoundError return, satisfying "always record the attempt". Note by rule 5 it runs even though the branches return.

Concrete trace — the missing-file path (shows the outer finally firing). Call read_report("nope.txt") on a file that does not exist:

  1. open("nope.txt") raises FileNotFoundError.
  2. The outer except FileNotFoundError matches → prepares to return "missing".
  3. Control is about to leave the construct, so by rule 5 the outer finally runs first → prints attempted.
  4. The return "missing" completes.

Printed: attempted. Returned: "missing". This is the demonstration that the outer finally fires even on the early except return — the next exercise traces the read-failure path.

(In real code you'd write with open(path) as f: — see Context managers — with statement and __enter__/__exit__ — which replaces the inner try/finally with the automatic __exit__ close. See also File handling in Python.)

L4.2

Trace the exact print order of read_report when open succeeds but read raises UnicodeDecodeError. What is returned?

Recall Solution
  1. open succeeds → into the else.
  2. Inner try: f.read() raises UnicodeDecodeError.
  3. Inner except matches → prepares to return "corrupt".
  4. Inner finally runs first (rule 5): f.close().
  5. Control is leaving the outer construct, so the outer finally runs: prints "attempted".
  6. The return "corrupt" completes.

Printed: attempted. Returned: "corrupt".


Level 5 — Mastery

(Reason about propagation, custom exceptions, and edge/degenerate cases — including exceptions raised while handling or cleaning up.)

L5.1

Consider a custom exception (see Raising exceptions — raise and custom exception classes):

class TooBig(ValueError):
    pass
 
def check(n):
    try:
        if n > 100:
            raise TooBig("over limit")
        return n
    except ValueError as e:
        return f"caught {type(e).__name__}"

What do check(50) and check(150) return, and why does the except ValueError catch a TooBig?

Recall Solution

check(50): 50 > 100 is False, so no raise; the try reaches return n → returns 50. check(150): 150 > 100 is Trueraise TooBig("over limit"). The clause's ClauseType is ValueError, and TooBig is defined as a subclass of ValueError, so isinstance(TooBig(...), ValueError) is True (rule 2) → the clause matches and runs. Inside it, type(e).__name__ reads the actual runtime class of the caught object, which is "TooBig" (not "ValueError"), so the returned string is "caught TooBig". Returns: check(50) == 50, check(150) == "caught TooBig". Why it catches: an except clause matches the listed class and all its subclasses. Since TooBig inherits from ValueError, any handler written for ValueError also catches every TooBig — this is exactly how custom exceptions slot cleanly into existing handlers.

L5.2

Degenerate case: what happens here — does the program crash?

def g():
    try:
        raise KeyError("k")
    except IndexError:
        return "handled"
    finally:
        print("cleanup")
Recall Solution

raise KeyError — but the only clause's ClauseType is IndexError, and KeyError is not an IndexError (they are siblings, both children of LookupError, neither is the other). No clause matches → by rule 3 the KeyError propagates outward. But rule 5 still applies: finally runs before the exception leaves the function → prints cleanup. Then the KeyError continues propagating; if nobody up the stack catches it, the program crashes with a traceback (see Debugging and tracebacks). Result: prints cleanup, then raises KeyError out of g(). The else-style "handled" path is never taken.

L5.3

Limiting behaviour: an exception is raised inside the except block itself. Predict the outcome, and say which exception ultimately leaves h().

def h():
    try:
        raise ValueError("first")
    except ValueError:
        raise RuntimeError("second")   # error while handling
    finally:
        print("finally ran")
Recall Solution

Step by step, applying the "only one in-flight exception" idea:

  1. try raises ValueError("first") → this becomes the in-flight exception.
  2. Python scans clauses; except ValueError matches (rule 2), so its body starts running. The ValueError is now considered being handled.
  3. Inside that body we raise RuntimeError("second"). This is a new exception raised while handling the old one, so it replaces the ValueError as the in-flight exception. Python does not re-try the other except clauses of this same construct for the new error — it is now propagating out.
  4. Rule 5: before control leaves h(), the finally runs → prints finally ran. (Note: the finally does not catch or suppress anything; it just runs on the way out.)
  5. The RuntimeError propagates out of h(). Because it arose during handling, Python chains the original ValueError as its __context__, and the traceback reads "During handling of the above exception (ValueError), another exception occurred".

Result: prints finally ran, then the exception that leaves h() is the RuntimeError (with the ValueError chained as context). The original ValueError is not what propagates — it was replaced. Why: a raise inside an except is a fresh error event; the handler did not finish "handling" quietly, so control does not fall through to normal flow — the new exception takes over.

L5.4

Now the exception is raised in the finally block. Which exception leaves k(), and what happened to the ValueError that was already propagating?

def k():
    try:
        raise ValueError("original")   # in-flight exception
    finally:
        raise RuntimeError("in finally")  # raised during cleanup
Recall Solution
  1. try raises ValueError("original"). There is no except, so this ValueError is in flight and about to leave the function — but rule 5 says finally must run first.
  2. The finally body runs and itself does raise RuntimeError("in finally"). This is a new exception raised during cleanup, so it overrides the in-flight ValueError — exactly the same replacement rule as L5.3, but now triggered from finally.
  3. What leaves k() is the RuntimeError, with the ValueError kept as its chained __context__ (traceback: "During handling of the above exception, another occurred").

Result: the exception propagating out of k() is the RuntimeError("in finally"); the original ValueError is suppressed as the propagating error (preserved only as context). Why this matters: a bug in your cleanup code can mask the real error the try was trying to report. This is exactly why you keep finally blocks tiny and non-risky — an exception (or return) in finally silently overrides whatever the try/except was going to do (compare L3.2, where a return in finally overrode a pending return; here a raise in finally overrides a pending raise).


Recall check

Which block runs only when the try succeeded with no exception?
The else block (rule 4).
In except A:, does it also catch subclasses of A?
Yes — it matches A (the ClauseType) and every subclass via isinstance.
Two sibling exceptions (KeyError, IndexError): does except IndexError catch a KeyError?
No — neither is a subclass of the other; the KeyError propagates.
If finally contains return, what happens to a pending return or exception from try?
The finally return overrides it — the earlier return value or exception is discarded (kept only as chained context if it was an exception).
If a raise happens inside an except block, which exception leaves the function?
The new one raised in the except; the original is replaced but chained as its context.
If a raise happens inside a finally block, which exception propagates?
The one raised in finally; it overrides any exception the try/except was already propagating.
Order rule for multiple except clauses?
Most specific (child) first, most general (parent) last; first match wins.
Does finally running mean the exception was handled?
No — finally runs on every exit, including uncaught propagation, and can even introduce a new exception.

Connections