1.3.7 · D2Python Intermediate

Visual walkthrough — Exception hierarchy

2,045 words9 min readBack to topic

This is the deep-dive companion to the parent Exception Hierarchy note. If you have never met try/except, read 1.3.06-TryExcept-Finally first — but you can follow every line here from scratch.

Before we can talk about a "family tree" of errors, we need two plain-English words nailed down.


Step 1 — Draw the family tree (this is the whole game board)

WHAT. Every error type in Python sits somewhere in one giant tree. At the very top is ==BaseException==. Everything else hangs below it.

WHY. A tree gives us exactly one path from any error up to the top. That single upward path is what Python will search when it hunts for a matching except. No tree ⇒ no rule.

PICTURE. Follow the arrows upward. Each arrow means "is a".

Figure — Exception hierarchy

Read the picture:

  • BaseException — the ultimate ancestor. Catching it catches literally everything.
  • Three "special" siblings hang directly off it: ==SystemExit== (from sys.exit()), ==KeyboardInterrupt== (you pressed Ctrl+C), GeneratorExit. These are not ordinary bugs — they are "please shut down" signals.
  • Exception — where ordinary errors live. Below it: ArithmeticError, LookupError, OSError, and dozens more.

Step 2 — One error, one upward path

WHAT. Suppose the running code raises KeyError('age') — you asked a dictionary for a key it does not have. We trace the only path from KeyError up to the top.

WHY. Python's search only ever moves upward along this path. It never sideways, never down. If we know the path, we can predict every catch. So let's isolate it.

PICTURE. The one path, lit up in orange:

Figure — Exception hierarchy

The lit path, bottom to top, is:

  • The first name is the type of the error that happened.
  • Each is read "is a": a KeyError is a LookupError, which is an Exception, which is a BaseException.
  • Anything not on this line — say ZeroDivisionError — can never catch our KeyError. It is on a different branch.

KeyError is-a LookupError? ::: Yes — LookupError is directly above it on the path. KeyError is-a ArithmeticError? ::: No — ArithmeticError is on a different branch, not on KeyError's upward path.


Step 3 — The matching rule, stated exactly

WHAT. Now the actual rule Python uses.

WHY. "First one on the path wins" is the entire behaviour. Everything else on this page is a consequence of this single line.

PICTURE. Left column = your except list, top to bottom. We test each against the lit path from Step 2 and stop at the first hit.

Figure — Exception hierarchy

Here the handlers are except IndexErrorexcept LookupErrorexcept Exception.

  • IndexError: is KeyError an IndexError? Look at the path — IndexError is not on it. ❌ skip.
  • LookupError: on the path? Yes.STOP — this block runs.
  • Exception: never even tested, because we already stopped.

Step 4 — Why "specific before general" is a law, not a style tip

WHAT. We put a general handler above a specific one, and watch the specific one die.

WHY. Because the rule says "first match on the path wins," a handler that is higher up the tree is a bigger net. Put the big net first and it scoops the error before the small net gets a turn.

PICTURE. Both Exception and KeyError are on the path — but Exception is written first, so it wins, and except KeyError becomes dead code.

Figure — Exception hierarchy
try:
    data[key]                # raises KeyError
except Exception as e:       # H1: Exception IS on KeyError's path → wins immediately
    print("General error")
except KeyError as e:        # H2: never reached — dead code
    print("Key missing")

The safe ordering — deepest node first, then walk up:

try:
    data[key]
except KeyError as e:        # deepest → checked first
    print(f"Missing key: {e}")
except LookupError as e:     # its parent → catches siblings like IndexError
    print(f"Lookup failed: {e}")
except Exception as e:       # grandparent → safety net for anything else
    print(f"Unexpected: {e}")

Step 5 — One handler for two errors (exploit a shared parent)

WHAT. A list lookup can raise IndexError; a dict lookup can raise KeyError. We want to catch both with one except.

WHY. Look back at Step 1: IndexError and KeyError are siblings — they share the parent LookupError. So LookupError sits on both their upward paths. One handler at the parent catches both children.

PICTURE. Two different errors, two different upward paths — but the paths merge at LookupError. Catching there covers both.

Figure — Exception hierarchy
def process_user_data(data, index, key):
    try:
        item  = data[index]      # may raise IndexError
        value = item[key]        # may raise KeyError
        return value
    except LookupError as e:     # sits on BOTH paths → catches either child
        print(f"Data lookup failed: {e}")
        return None
 
process_user_data([{'name': 'Alice'}], 0, 'name')  # -> 'Alice'
process_user_data([{'name': 'Alice'}], 5, 'name')  # IndexError -> LookupError handler -> None
process_user_data([{'name': 'Alice'}], 0, 'age')   # KeyError  -> LookupError handler -> None

Use this only when you want the same recovery for both. If you need different messages, go back to two child handlers (Step 4 ordering).


Step 6 — The degenerate case: BaseException vs Exception (Ctrl+C)

WHAT. The edge case everyone gets bitten by. You press Ctrl+C to kill a stuck program. Python turns that keypress into a KeyboardInterrupt. Which handlers catch it?

WHY. KeyboardInterrupt hangs directly off BaseException — it is NOT below Exception (re-read Step 1!). So its upward path is short and skips Exception entirely:

PICTURE. The Ctrl+C path (left) never touches Exception. So except Exception misses it — good, your Ctrl+C works — while except BaseException swallows it — bad, your program refuses to die.

Figure — Exception hierarchy
  • except Exception:Exception is not on the KeyboardInterrupt path → does not catch Ctrl+C. ✅ The program still exits when you hit Ctrl+C.
  • except BaseException:BaseException is on the path → catches Ctrl+C → your loop keeps running forever. ❌

Which shutdown signals live directly under BaseException, not under Exception? ::: SystemExit, KeyboardInterrupt, GeneratorExit


Step 7 — The other degenerate case: no handler matches

WHAT. What if the error's upward path contains none of your handler types?

WHY. The rule from Step 3 finds "the first on the path". If there is no such , the search reaches the top with no match — Python gives up on this try and re-raises the error to the code that called you. If nobody up the call stack catches it either, the program crashes with a traceback.

PICTURE. The path is walked to BaseException, every handler misses, and the error escapes the try block entirely.

Figure — Exception hierarchy
try:
    1 / 0                    # ZeroDivisionError
except KeyError:             # KeyError not on ZeroDivisionError's path
    ...                      # no match anywhere -> error propagates -> crash

The takeaway: a try/except only protects you against errors whose type is on the path of one of your handlers. Everything else sails straight through. This is why you sometimes want a final except Exception safety net — it sits on every ordinary error's path.


The one-picture summary

Figure — Exception hierarchy

Everything compressed: an error is born at some node; Python lights its upward path; it scans your except list top to bottom; the first handler that lies on the lit path wins; parents are bigger nets (put them last); the three shutdown signals sit off to the side under BaseException where except Exception cannot reach them.

Recall Feynman retelling — say it like a story

Picture a family tree of error types, BaseException at the crown. When something breaks, Python makes an error object and, from that error's spot in the tree, draws a straight line upward to the crown — that line is the error's ancestors, its "is-a" chain. Now Python reads your except blocks one by one, top to bottom, and asks each: "Is your type anywhere on this upward line?" The first one that says yes runs; the rest are ignored. That single fact explains all four lessons: (1) a parent written above its child steals the child's turn, so put specific children first — Step 4. (2) Two sibling errors share a parent, so one handler at that parent catches both — Step 5. (3) Ctrl+C's error hangs off the crown itself, skipping Exception, which is exactly why except Exception lets Ctrl+C kill the program while except BaseException traps it — Step 6. (4) If no handler is on the line, the error walks off the top and crashes out to whoever called you — Step 7. Learn the tree, trace one upward line, read top to bottom, take the first hit. That is the entire mechanism.

Recall Quick self-check

Handlers run in which order relative to your source? ::: Top to bottom; first match on the error's upward path wins. Where must specific (child) handlers go? ::: Before (above) their parent handlers, or they become dead code. Does except Exception catch KeyboardInterrupt? ::: No — KeyboardInterrupt is under BaseException, not Exception. Two siblings IndexError and KeyError — one handler for both? ::: except LookupError (their shared parent). What happens if no handler is on the path? ::: The exception propagates up the call stack; if uncaught, the program crashes.

Ready to build your own nodes on this tree? See 1.3.08-Custom-Exceptions.