مرکزی مواد پر جائیں

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]

Press Shift+Tab to enter Plan Mode before predicting.

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

Press Shift+Tab to exit Plan Mode.

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? Use /investigate @hierarchy_practice.py in Claude Code and 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.

Prompt 3: Test That Exceptions Are Raised

Write a pytest test for a function called parse_priority
that takes a string and returns an int between 1 and 5.
The test should verify that passing "zero" raises
ValueError and passing None raises TypeError. Use
pytest.raises for each case.

Review the AI's test code. Does it use pytest.raises(ValueError) and pytest.raises(TypeError) correctly? Does it match the decision table from this lesson (wrong value = ValueError, wrong type = TypeError)? Try running the tests yourself.

What you're learning: You are connecting error handling to testing by verifying that your functions raise the correct exception types.



James studied the exception hierarchy diagram. "It's an org chart. BaseException is the CEO. Exception is the COO who handles day-to-day operations. KeyboardInterrupt and SystemExit report directly to the CEO and skip normal channels. That's why catching the COO doesn't intercept messages meant for the CEO."

Emma blinked. "I've been explaining this hierarchy for two years and I've never framed it that way. The org chart thing makes the 'bare except catches the CEO's mail' problem immediately obvious."

"In the warehouse, we had a similar thing. The floor supervisor handled routine issues: damaged items, mislabeled pallets, short counts. But a fire alarm went straight to the building manager. If the floor supervisor intercepted the fire alarm and 'handled' it by logging a ticket, the building would burn down."

"That's except Exception vs bare except in one sentence," Emma said. "I'm using that."

James grinned. "So finally is the cleanup crew that runs no matter what. Whether the shipment arrived intact or the whole pallet was rejected, someone still has to sweep the dock."

"Exactly. But writing finally for every cleanup gets repetitive. There's a cleaner way to handle it, especially for files. The with statement guarantees cleanup without the finally boilerplate. That's where we're headed next."