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.

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
      +-- MemoryError

Why 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"))        # None
def 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 parentTo handle
LookupErrorIndexError, KeyError
ArithmeticErrorZeroDivisionError, OverflowError
OSErrorFiles, permissions, sockets, timeouts
ValueErrorBad values, including Unicode decoding failures
ExceptionEverything 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__)                     # FileNotFoundError

Exception 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 KeyError
Traceback (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 required

raise ... 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__)          # None

Exception 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 BaseException or writing a bare except:.
  • Placing a parent handler above a child handler, making the child unreachable.
  • Catching Exception in library code, so callers cannot react to specific failures.
  • Raising a new exception inside a handler without from, producing a confusing traceback.
  • Catching StopIteration by accident inside a generator.
  • Assuming FileNotFoundError covers permission problems. It does not; OSError does.

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 error when translating an exception across a layer boundary.
  • Reserve except Exception for the outermost layer of an application, where it logs and exits cleanly.

Practice

  1. Write handlers for IndexError and KeyError using one parent class, and say which parent.
  2. Demonstrate an unreachable handler and explain what makes it unreachable.
  3. Show why except Exception still allows Ctrl+C but a bare except: does not.
  4. Translate a KeyError into a domain specific error with from, and print both.
  5. Draw the ancestry of FileNotFoundError from 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.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.