The Exception Hierarchy
Every exception is a class in one inheritance tree. Catching a parent catches all its children, which is what makes handler order and handler width matter.
- Basics
- Data Types
- Operators
- Strings
- Control Flow
- Lists
- Tuples
- Sets
- Dictionaries
- Comprehensions
- Functions
- Advanced Functions
- Recursion
- Exception Handling
- File Handling
- Modules
- Standard Library
- OOP
- Advanced OOP
- Iterators and Generators
- Decorators
- Context Managers
- Descriptors and Dataclasses
- Python Internals
- Concurrency
- Regular Expressions
- Serialization
- Command Line Python
- Testing and Debugging
- Type Hints
- Performance
- Python Security
- DSA with Python
Exceptions are classes
print(ValueError.__mro__)(<class 'ValueError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)Catching an exception type catches that type and every subclass of it. That single rule explains almost everything about how handlers behave.
The tree
BaseException
+-- SystemExit sys.exit() was called
+-- KeyboardInterrupt Ctrl+C
+-- GeneratorExit a generator was closed
+-- Exception <-- everything you should normally catch
+-- ArithmeticError
| +-- ZeroDivisionError
| +-- OverflowError
| +-- FloatingPointError
+-- LookupError
| +-- IndexError
| +-- KeyError
+-- OSError
| +-- FileNotFoundError
| +-- PermissionError
| +-- IsADirectoryError
| +-- FileExistsError
| +-- TimeoutError
| +-- ConnectionError
| +-- ConnectionResetError
| +-- BrokenPipeError
+-- NameError
| +-- UnboundLocalError
+-- TypeError
+-- ValueError
| +-- UnicodeError
| +-- UnicodeDecodeError
| +-- UnicodeEncodeError
+-- AttributeError
+-- ImportError
| +-- ModuleNotFoundError
+-- RuntimeError
| +-- RecursionError
| +-- NotImplementedError
+-- StopIteration
+-- AssertionError
+-- MemoryErrorWhy BaseException matters
try:
long_running_task()
except Exception: # correct: Ctrl+C still works
print("task failed")
try:
long_running_task()
except BaseException: # wrong: swallows Ctrl+C and sys.exit()
print("task failed")KeyboardInterrupt and SystemExit sit outside Exception deliberately, so that except Exception does not trap them. Catch Exception, never BaseException, and never write a bare except: which is equivalent to the latter.
Handler order
data = [1, 2, 3]
# WRONG - LookupError is a parent of IndexError, so the second handler is dead
try:
print(data[10])
except LookupError:
print("lookup failed")
except IndexError:
print("index out of range") # unreachable
# RIGHT - specific first
try:
print(data[10])
except IndexError:
print("index out of range")
except LookupError:
print("some other lookup failed")Python checks handlers top to bottom and runs the first one that matches. A broad handler placed above a narrow one makes the narrow one unreachable.
Catching a parent on purpose
def fetch(container, key):
try:
return container[key]
except LookupError: # covers IndexError AND KeyError
return None
print(fetch([1, 2], 5)) # None
print(fetch({"a": 1}, "b")) # Nonedef read_config(path):
try:
with open(path) as handle:
return handle.read()
except OSError as error: # missing, permission denied, is a directory...
print(f"cannot read {path}: {error}")
return ""OSError is the single most useful parent to catch. Every filesystem and network failure is one of its children, and handling them individually is rarely worth the lines.
Grouping by parent
| Catch this parent | To handle |
|---|---|
LookupError | IndexError, KeyError |
ArithmeticError | ZeroDivisionError, OverflowError |
OSError | Files, permissions, sockets, timeouts |
ValueError | Bad values, including Unicode decoding failures |
Exception | Everything you should ever catch broadly |
Testing membership
print(issubclass(FileNotFoundError, OSError)) # True
print(issubclass(KeyError, LookupError)) # True
print(issubclass(KeyboardInterrupt, Exception)) # False
try:
raise FileNotFoundError("missing")
except Exception as error:
print(isinstance(error, OSError)) # True
print(type(error).__name__) # FileNotFoundErrorException chaining
def load_setting(config, key):
try:
return config[key]
except KeyError as error:
raise ValueError(f"setting {key} is required") from error
try:
load_setting({}, "timeout")
except ValueError as error:
print(error) # setting timeout is required
print(error.__cause__) # 'timeout' - the original KeyErrorTraceback (most recent call last):
...
KeyError: 'timeout'
The above exception was the direct cause of the following exception:
Traceback (most recent call last):
...
ValueError: setting timeout is requiredraise ... from ... translates a low level failure into one that means something to the caller, while keeping the original in the traceback. Without from, Python still shows the original under "During handling of the above exception, another exception occurred", which reads as an accident rather than a decision.
try:
try:
int("abc")
except ValueError:
raise RuntimeError("parsing failed") from None # hide the original
except RuntimeError as error:
print(error.__cause__) # NoneException groups
def validate(record):
problems = []
if not record.get("name"):
problems.append(ValueError("name is required"))
if record.get("age", 0) < 0:
problems.append(ValueError("age cannot be negative"))
if problems:
raise ExceptionGroup("validation failed", problems)
try:
validate({"age": -5})
except* ValueError as group:
for error in group.exceptions:
print("-", error)ExceptionGroup and except* arrived in Python 3.11, for cases where several independent failures happen at once - validating every field rather than stopping at the first, or gathering results from concurrent tasks.
A layered example
def parse_record(line):
try:
name, age = line.split(",")
return {"name": name.strip(), "age": int(age)}
except ValueError as error:
raise ValueError(f"bad record: {line!r}") from error
def load(lines):
records, failures = [], []
for line in lines:
try:
records.append(parse_record(line))
except ValueError as error:
failures.append(str(error))
return records, failures
records, failures = load(["Meera, 27", "broken line", "Arun, x"])
print(records)
for failure in failures:
print("skipped:", failure)Note that one except ValueError in parse_record covers two different failures - too few values to unpack, and an unparsable number - because both raise ValueError. Adding context with from is what makes the message useful.
Common mistakes
- Catching
BaseExceptionor writing a bareexcept:. - Placing a parent handler above a child handler, making the child unreachable.
- Catching
Exceptionin library code, so callers cannot react to specific failures. - Raising a new exception inside a handler without
from, producing a confusing traceback. - Catching
StopIterationby accident inside a generator. - Assuming
FileNotFoundErrorcovers permission problems. It does not;OSErrordoes.
Best practices
- Catch the narrowest type that describes what you expect to fail.
- Catch a parent deliberately when the whole family should be handled the same way.
- Order handlers from specific to general.
- Use
raise ... from errorwhen translating an exception across a layer boundary. - Reserve
except Exceptionfor the outermost layer of an application, where it logs and exits cleanly.
Practice
- Write handlers for
IndexErrorandKeyErrorusing one parent class, and say which parent. - Demonstrate an unreachable handler and explain what makes it unreachable.
- Show why
except Exceptionstill allows Ctrl+C but a bareexcept:does not. - Translate a
KeyErrorinto a domain specific error withfrom, and print both. - Draw the ancestry of
FileNotFoundErrorfrom memory, then check it with__mro__.
Conclusion
Exceptions form one inheritance tree, and catching a class catches its descendants. Order handlers specific to general, catch Exception rather than BaseException, and use raise ... from when you translate a failure into your own vocabulary.