The Exception Hierarchy and Best Practices
James catches a ValueError in one function and a KeyError in another. He wonders: are these two completely separate things, or are they related somehow? He tries catching Exception and both errors are caught. He tries catching ValueError and the KeyError slips through.
"All exceptions are organized in a family tree," Emma explains. "ValueError and KeyError are siblings. They both descend from Exception. If you catch the parent, you catch all the children. If you catch one child, the other children pass right through."
Understanding this hierarchy helps you write except clauses that catch exactly what you intend: not too broad (swallowing errors you should see) and not too narrow (missing errors you should handle).
Think of exceptions like a filing system. All error reports go into a main folder called Exception. Inside that folder are subfolders: ValueError for bad values, TypeError for wrong types, KeyError for missing dictionary keys. When you tell Python to "catch everything in the ValueError folder," it only catches value errors. When you say "catch everything in the Exception folder," it catches all of them.
Python's hierarchy is rooted at BaseException, not Exception. KeyboardInterrupt and SystemExit inherit from BaseException directly, which is why except Exception does not catch them. This is similar to Java's Error vs Exception distinction.
The Built-in Exception Hierarchy
Python organizes exceptions in a tree. Here are the parts you will encounter most often:
BaseException
├── KeyboardInterrupt (Ctrl+C, do NOT catch)
├── SystemExit (sys.exit(), do NOT catch)
└── Exception (safe to catch)
├── ValueError (right type, wrong value)
├── TypeError (wrong type entirely)
├── KeyError (missing dictionary key)
├── IndexError (list index out of range)
├── FileNotFoundError (file does not exist)
├── PermissionError (no permission to access file)
├── ZeroDivisionError (division by zero)
├── AttributeError (object has no attribute)
└── json.JSONDecodeError (invalid JSON data)
The key insight: KeyboardInterrupt and SystemExit are NOT under Exception. That is why except Exception is safe as a catch-all, but bare except: is dangerous. Bare except: catches BaseException, which includes Ctrl+C.
Exception Decision Table
When you need to raise an exception, use this table to choose the right type:
| Situation | Exception | Example |
|---|---|---|
| Right type, wrong value | ValueError | int("hello"), negative word count |
| Wrong type entirely | TypeError | Passing int where str expected |
| Missing dictionary key | KeyError | note["author"] when key absent |
| List index out of bounds | IndexError | items[10] in a 3-item list |
| File does not exist | FileNotFoundError | open("missing.json") |
| Division by zero | ZeroDivisionError | 100 / 0 |
| Invalid JSON | json.JSONDecodeError | json.loads("{broken") |
When in doubt between ValueError and TypeError, ask: "Is the type correct but the value wrong?" If yes, use ValueError. If the type itself is wrong, use TypeError.
except Exception as e as Safe Catch-All
Sometimes you need to catch any error without knowing the specific type. Use except Exception, not bare except::
def safe_process(data: str) -> str:
"""Process data, returning an error message on any failure."""
try:
# Some complex processing
result: str = data.upper()
return result
except Exception as e:
return f"Processing failed: {e}"
This catches ValueError, TypeError, KeyError, and every other Exception subclass. It does NOT catch KeyboardInterrupt or SystemExit, so users can still press Ctrl+C to stop the program.
Use except Exception sparingly. Prefer specific exception types when you know what can go wrong. Reserve the broad catch for top-level error handlers where you want to log the error and continue rather than crash.
The finally Clause
The finally clause runs no matter what: whether the try block succeeds, an exception is caught, or an exception escapes. It guarantees cleanup:
def read_config(path: str) -> str:
"""Read a config file, always printing a status message."""
status: str = "unknown"
try:
with open(path) as f:
content: str = f.read()
status = "success"
return content
except FileNotFoundError:
status = "file not found"
return ""
finally:
print(f"Config read attempt: {status}")
No matter which path executes (success or FileNotFoundError), the finally block prints the status. Even if an unexpected exception escapes both the try and except blocks, finally still runs before the exception propagates.
Testing finally Behavior
You can verify that finally runs on both paths:
import pytest
call_log: list[str] = []
def tracked_divide(a: float, b: float) -> float:
"""Divide a by b, logging the attempt."""
try:
result: float = a / b
call_log.append("success")
return result
except ZeroDivisionError:
call_log.append("error")
raise
finally:
call_log.append("cleanup")
def test_divide_success_runs_finally() -> None:
call_log.clear()
tracked_divide(10.0, 2.0)
assert call_log == ["success", "cleanup"]
def test_divide_error_runs_finally() -> None:
call_log.clear()
with pytest.raises(ZeroDivisionError):
tracked_divide(10.0, 0.0)
assert call_log == ["error", "cleanup"]
Both tests confirm that "cleanup" appears in the log regardless of whether the division succeeded or failed. The finally clause runs in both cases.
Combining try/except/else/finally
All four clauses can appear together. The execution order is predictable:
def full_example(raw: str) -> int:
"""Demonstrate all four clauses."""
try:
value: int = int(raw)
except ValueError as e:
print(f"EXCEPT: {e}")
return -1
else:
print(f"ELSE: parsed {value}")
return value
finally:
print("FINALLY: always runs")
full_example("42")
# Output:
# ELSE: parsed 42
# FINALLY: always runs
full_example("oops")
# Output:
# EXCEPT: invalid literal for int() with base 10: 'oops'
# FINALLY: always runs
The pattern: try runs first. If it fails, except runs. If it succeeds, else runs. Either way, finally runs last. You rarely need all four together, but knowing the order helps you read code that uses them.
Python lets you define your own exception types by creating classes that inherit from Exception. This requires understanding classes, inheritance, and the class keyword, which you will learn in Phase 5. For now, the built-in exceptions (ValueError, TypeError, KeyError, FileNotFoundError) cover every situation you will encounter.
PRIMM-AI+ Practice: Exception Hierarchy
Predict [AI-FREE]
Look at this code without running it. For each scenario, predict whether the exception is caught or escapes. Write your predictions and a confidence score from 1 to 5 before checking.
def careful_lookup(data: dict[str, int], key: str) -> int:
try:
return data[key]
except ValueError:
return -1
finally:
print("Lookup complete")
# Scenario 1
result1 = careful_lookup({"a": 1, "b": 2}, "a")
# Scenario 2
result2 = careful_lookup({"a": 1, "b": 2}, "c")
Check your predictions
Scenario 1: Returns 1. The key "a" exists, so data["a"] returns 1 without raising anything. finally prints "Lookup complete".
Scenario 2: The except ValueError does NOT catch it. Accessing data["c"] raises a KeyError, not a ValueError. The except ValueError clause does not match KeyError, so the exception escapes. But finally still prints "Lookup complete" before the program crashes.
The key lesson: KeyError and ValueError are siblings in the hierarchy. Catching one does not catch the other. You would need except KeyError to handle this case.
Run
Create a file called hierarchy_practice.py with the function. Wrap Scenario 2 in a try/except KeyError block so it does not crash. Run uv run python hierarchy_practice.py and verify.
Investigate
Change the except ValueError to except Exception. Now does Scenario 2 get caught? Why? Trace through the hierarchy to explain.
Modify
Add a second except clause for KeyError (before the except ValueError). Predict what happens for both scenarios, then run to verify.
Hint
Python checks except clauses top to bottom. If except KeyError appears first, it catches the KeyError from Scenario 2. The except ValueError would only trigger if something raised a ValueError inside the try block.
Make [Mastery Gate]
Without looking at any examples, write a function called safe_lookup(data: dict[str, int], key: str) -> int that:
- Returns the value if the key exists
- Returns
0if the key is missing (KeyError) - Always prints
f"Looked up: {key}"in afinallyblock
Write three tests:
test_key_exists: verifiessafe_lookup({"x": 5}, "x")returns5test_key_missing: verifiessafe_lookup({"x": 5}, "y")returns0test_finally_runs(usecapsys): verifies"Looked up:"appears in printed output
Run uv run pytest to verify all tests pass.
Try With AI
If Claude Code is not already running, open your terminal, navigate to your SmartNotes project folder, and type claude. If you need a refresher, Chapter 44 covers the setup.
Prompt 1: Explore the Hierarchy
Show me the Python exception hierarchy from BaseException
down to the most common exceptions. Explain why
KeyboardInterrupt is not under Exception.
Read the AI's response. Does it correctly place KeyboardInterrupt under BaseException (not Exception)? Does it explain that this is why except Exception is safe but bare except: is not? Compare to the hierarchy diagram in this lesson.
What you're learning: You are verifying the AI's understanding of the hierarchy against what you learned.
Prompt 2: Decision Table Practice
I have a function that reads a value from a dictionary,
converts it to an integer, and checks that it is positive.
What exception should I raise for each of these cases:
1. The key does not exist in the dictionary
2. The value cannot be converted to an integer
3. The integer is negative
Give me the exception type and a sample error message
for each.
Review the AI's answer. Does it match the decision table from this lesson? Key answers: KeyError for case 1, ValueError for cases 2 and 3.
What you're learning: You are testing whether the AI applies the same exception selection rules you learned.
Key Takeaways
-
Exceptions form a hierarchy. All catchable exceptions descend from
Exception.KeyboardInterruptandSystemExitsit outside it underBaseException, which is why bareexcept:is dangerous. -
Use the decision table to pick the right exception.
ValueErrorfor wrong values,TypeErrorfor wrong types,KeyErrorfor missing keys,FileNotFoundErrorfor missing files. -
except Exception as eis the safe catch-all. It catches all normal errors without swallowing Ctrl+C or system exits. Use it sparingly at top-level handlers. -
finallyalways runs. Whether the try block succeeds, an exception is caught, or an exception escapes, the finally block executes. Use it for cleanup that must happen no matter what. -
Combine clauses when needed.
try/except/else/finallygives you four hooks: attempt, error, success, cleanup. You rarely need all four, but knowing the execution order helps you read and write robust code.
Looking Ahead
You now understand the exception hierarchy and the finally clause. In Lesson 4, you will apply this knowledge to real file operations: opening JSON files with with open, handling FileNotFoundError and json.JSONDecodeError, and understanding why with replaces most uses of finally.