Skip to main content

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).

If you're new to programming

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.

If you know exceptions from another language

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:

SituationExceptionExample
Right type, wrong valueValueErrorint("hello"), negative word count
Wrong type entirelyTypeErrorPassing int where str expected
Missing dictionary keyKeyErrornote["author"] when key absent
List index out of boundsIndexErroritems[10] in a 3-item list
File does not existFileNotFoundErroropen("missing.json")
Division by zeroZeroDivisionError100 / 0
Invalid JSONjson.JSONDecodeErrorjson.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.

Custom exceptions

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 0 if the key is missing (KeyError)
  • Always prints f"Looked up: {key}" in a finally block

Write three tests:

  1. test_key_exists: verifies safe_lookup({"x": 5}, "x") returns 5
  2. test_key_missing: verifies safe_lookup({"x": 5}, "y") returns 0
  3. test_finally_runs (use capsys): verifies "Looked up:" appears in printed output

Run uv run pytest to verify all tests pass.


Try With AI

Opening Claude Code

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

  1. Exceptions form a hierarchy. All catchable exceptions descend from Exception. KeyboardInterrupt and SystemExit sit outside it under BaseException, which is why bare except: is dangerous.

  2. Use the decision table to pick the right exception. ValueError for wrong values, TypeError for wrong types, KeyError for missing keys, FileNotFoundError for missing files.

  3. except Exception as e is the safe catch-all. It catches all normal errors without swallowing Ctrl+C or system exits. Use it sparingly at top-level handlers.

  4. finally always 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.

  5. Combine clauses when needed. try/except/else/finally gives 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.