1.3.7 · D4Python Intermediate

Exercises — Exception hierarchy

2,381 words11 min readBack to topic

Before we start, one picture of the slice of the tree we will keep using — pin it in your head.

Figure — Exception hierarchy

Read it as: an arrow means "is a kind of". KeyError is a kind of LookupError, which is a kind of Exception, which is a kind of BaseException. A handler for a box catches that box and everything below it. Notice the whole left branch under BaseExceptionKeyboardInterrupt, SystemExit, GeneratorExit — sits outside Exception on purpose; that placement is the punchline of half these problems.


Level 1 — Recognition

Recall Solution L1.1

Walk up one step in the figure.

  • (a) ZeroDivisionError → parent is ==ArithmeticError==.
  • (b) IndexError → parent is ==LookupError==.
  • (c) FileNotFoundError → parent is ==OSError==.
  • (d) KeyboardInterrupt → parent is ==BaseException== directly (NOT Exception). This is the key one: Ctrl+C lives outside the Exception branch on purpose, so except Exception never swallows it.
Recall Solution L1.2

Rule: a handler catches a box only if the raised exception is that box or a descendant of it (arrow-path leading up to it).

  • (a) YesKeyError is below LookupError.
  • (b) NoIndexError and KeyError are siblings; neither is below the other.
  • (c) NoSystemExit sits under BaseException but not under Exception.
  • (d) YesZeroDivisionErrorArithmeticErrorException; the path reaches Exception.

Level 2 — Application

Recall Solution L2.1

Trace the two calls, then remember each outer print prints whatever f returned.

  • f(10, 2): no exception, f returns 5.0 (a float — / always makes a float). f prints nothing itself. The outer print(f(10, 2)) then prints 5.0.
  • f(10, 0): dividing by zero raises ZeroDivisionError. Handlers are checked top→bottom; the first, except ZeroDivisionError, matches, so inside f it prints A and returns None. The outer print(f(10, 0)) then prints None.

So there are three screen lines total — one from inside f (A) and one from each of the two outer prints (5.0, None):

5.0
A
None

(The order is: outer print of call 1 → the A printed inside call 2 → outer print of call 2.)

Recall Solution L2.2

Both IndexError and KeyError sit under LookupError, so one parent handler covers both:

try:
    item = data[index]
    value = item[key]
except LookupError:
    print("lookup failed")

This is the 80/20 move from Example 3 in the parent: use the nearest common ancestor when you don't need different recovery logic for each child.

Recall Solution L2.3

List the classes in a parenthesised tuple after except:

try:
    item = data[index]
    value = item[key]
except (KeyError, IndexError) as e:
    print(f"lookup failed: {e}")

The parentheses are required — except KeyError, IndexError: is a syntax error in Python 3. This handler catches an exception if it is a descendant of any class in the tuple. When to prefer the tuple over except LookupError: when you want to catch a specific pair of siblings that live under a broad parent whose other children you deliberately want to let through. except LookupError would also grab those other children; the tuple names only the two you chose. It's also handy for pulling together cousins from different branches, e.g. except (ValueError, KeyError):, which have no useful common ancestor short of Exception.


Level 3 — Analysis

Recall Solution L3.1

FileNotFoundError and PermissionError are both children of OSError. Since except OSError is written first and it catches its own descendants, any FileNotFoundError or PermissionError is grabbed at line (1).

  • Reachable: (1) only.
  • Dead code: (2) and (3) — never reached. Fix ordering: put the specific children first:
except FileNotFoundError: ...
except PermissionError:   ...
except OSError:           ...   # safety net last
Recall Solution L3.2

Ctrl+C raises KeyboardInterrupt. In the tree, KeyboardInterrupt hangs directly off BaseException, on a different branch from Exception (the same branch as SystemExit and GeneratorExit). A handler catches only its box and descendants, and KeyboardInterrupt is not a descendant of Exception. So the except Exception never matches it, the exception keeps propagating up to BaseException, finds no handler, and terminates the program — exactly the safe behaviour we want.


Level 4 — Synthesis

Recall Solution L4.1
def read_config(path):
    try:
        with open(path, "r") as f:
            return f.read()
    except FileNotFoundError:   # specific child first
        return "DEFAULT"
    except OSError:             # parent safety net (still under Exception,
        return None             # so KeyboardInterrupt is untouched)

Why this satisfies (4): neither FileNotFoundError nor OSError is an ancestor of KeyboardInterrupt, so Ctrl+C passes straight through. We never wrote except BaseException, which would have been the only way to accidentally trap it. See 1.3.09-Context-Managers for why with open(...) also closes the file safely on error.

Recall Solution L4.2

Order every handler so no ancestor blocks a needed descendant:

try:
    work()
except ZeroDivisionError:   # child of ArithmeticError
    print("zero")
except ArithmeticError:     # parent — catches OverflowError etc.
    print("math")
except LookupError:         # covers IndexError + KeyError
    print("lookup")
except Exception:           # everything else, but not SystemExit/Ctrl+C
    print("other")

Check the tree: ZeroDivisionError before its parent ArithmeticError ✓; LookupError handles both sibling children ✓; Exception last as the broad net that still excludes the BaseException-only branch ✓.


Level 5 — Mastery

Recall Solution L5.1

Inherit from Exception:

class ConfigError(Exception):
    pass

Why: subclassing Exception (not BaseException) drops ConfigError onto the normal-errors branch. Now except Exception catches it, except ConfigError catches it specifically, and it stays off the system-signal branch — so it never interferes with SystemExit/KeyboardInterrupt. This is the standard rule; full treatment in 1.3.08-Custom-Exceptions, and the inheritance mechanics come from 2.1.04-Inheritance.

Recall Solution L5.2

The key fact students get wrong: Python's built-in float arithmetic operators (+ - * /) do NOT raise OverflowError — they return the special float inf on overflow. OverflowError comes from places that refuse to produce inf, such as many math functions and conversions.

  • (a) 1e308 / 1e-10: the true value exceeds the largest finite float (). The / operator does not raise — it returns float('inf').
  • (b) 10**1000 / 10**999: 10**1000 and 10**999 are arbitrary-precision integers; their exact ratio is , which fits easily, so / returns the float 10.0 — no overflow at all.
  • (c) math.exp(1000): math.exp would need , far past the float range, and unlike / it raises OverflowError rather than returning inf. Lesson: overflow behaviour depends on who does the arithmetic. Plain float operators → inf; certain math/conversion routines → OverflowError; unbounded Python ints → never overflow.

Recall Self-test: fill the reveal

A handler catches an exception when the exception is that class or a ::: descendant of it (following arrows upward) Order handlers from ::: specific (children) to general (parents), top to bottom except Exception deliberately does NOT catch ::: KeyboardInterrupt, SystemExit and GeneratorExit (they sit under BaseException only) To catch two specific siblings in one line without naming their parent, write ::: except (KeyError, IndexError) 1e308 / 1e-10 evaluates to ::: inf (the float / operator returns inf, it does not raise OverflowError) 10**1000 / 10**999 returns ::: 10.0 (no overflow — Python ints are unbounded) A custom app error should subclass ::: Exception (not BaseException)