Skip to main content

Exception Hierarchy - Python's Built-in Exception Tree

Reading time: ~16 minutes | Level: Foundation → Engineering

Here is a fact that catches experienced developers off guard:

try:
open("/nonexistent/path/config.json")
except OSError as e:
print(f"Caught: {type(e).__name__}")
print(f"Is FileNotFoundError: {isinstance(e, FileNotFoundError)}")
print(f"Is OSError: {isinstance(e, OSError)}")

Output:

Caught: FileNotFoundError
Is FileNotFoundError: True
Is OSError: True

except OSError caught a FileNotFoundError. That is because FileNotFoundError is a subclass of OSError. When you catch a parent class, you catch every subclass.

This is not just trivia - it shapes every except clause you write. Catching Exception catches everything. Catching OSError catches all file, network, and process errors. Catching FileNotFoundError catches only missing-file errors.

The hierarchy is the map you need to write correct, targeted exception handling.

What You Will Learn

  • The complete built-in exception hierarchy as a navigable ASCII tree
  • Why SystemExit, KeyboardInterrupt, and GeneratorExit live under BaseException directly
  • The ArithmeticError branch: ZeroDivisionError, OverflowError, FloatingPointError
  • The LookupError branch: IndexError and KeyError
  • The OSError consolidation: how a dozen old names map to one hierarchy
  • The semantic distinction between ValueError and TypeError
  • RuntimeError, RecursionError, and NotImplementedError
  • How StopIteration drives for loops and generators
  • The Warning hierarchy and how warnings differ from exceptions
  • The golden rule: always catch the most specific exception that makes sense

Prerequisites

  • Python exceptions as objects and the raise keyword (see: Exceptions Explained)
  • Basic Python class inheritance (isinstance, subclassing)
  • Familiarity with common Python errors you have seen in the REPL

The Complete Built-in Hierarchy

This tree tells you exactly what except SomeException will catch: SomeException itself plus every class in the subtree below it.

Part 1 - The BaseException Root and Its Direct Children

Why BaseException Exists at All

Before Python 2.6, you could raise any object - including strings. Python 2.6 formalized the hierarchy. BaseException is the mandatory root for all exceptions, but it is intentionally separate from Exception so that three special cases can exist:

ExceptionRaised ByNote
SystemExitsys.exit()Termination signal - almost never catch without re-raising
KeyboardInterruptCtrl+CTermination signal - almost never catch without re-raising
GeneratorExitgenerator.close()Raised inside the generator - almost never catch without re-raising

These are NOT program errors. They are termination signals.

SystemExit

import sys

# sys.exit() raises SystemExit - it is an exception, not a C call
try:
sys.exit(42)
except SystemExit as e:
print(f"SystemExit caught, code: {e.code}") # SystemExit caught, code: 42
# We are NOT exiting - we caught it. Don't do this.

# The exit code is available in e.code
# sys.exit(0) → success
# sys.exit(1) → failure
# sys.exit("error message") → prints message to stderr, exits with code 1

SystemExit is the only acceptable exception to catch without re-raising - but only when you need to do cleanup before exit, and you must then re-raise or call sys.exit() again.

KeyboardInterrupt

import time

# Ctrl+C in the terminal raises KeyboardInterrupt
try:
print("Running... press Ctrl+C to stop")
while True:
time.sleep(0.1)
except KeyboardInterrupt:
print("\nGraceful shutdown - cleaning up")
# Do cleanup: close files, flush buffers, save state
# Then let the program exit normally

This is the correct way to handle Ctrl+C: catch it, clean up, and exit. Do not catch it and then silently continue - that prevents the user from stopping the program.

GeneratorExit

def my_generator():
try:
yield 1
yield 2
yield 3
except GeneratorExit:
print("Generator is being closed - release resources here")
# Do NOT yield here - just clean up and return
return # or just fall off the end

gen = my_generator()
print(next(gen)) # 1
gen.close() # raises GeneratorExit inside the generator
# Output:
# 1
# Generator is being closed - release resources here

GeneratorExit is raised by the interpreter when a generator's .close() method is called or when a generator is garbage collected before being exhausted.

Part 2 - ArithmeticError and Its Subclasses

ExceptionRaised When
ZeroDivisionErrorDivision or modulo by zero
OverflowErrorResult too large to represent as a float
FloatingPointErrorFloating-point operation failure (rare - requires signal traps to be enabled)

All three inherit from ArithmeticError; catching ArithmeticError catches all of them.

# ZeroDivisionError: integer and float division by zero
try:
result = 10 / 0
except ZeroDivisionError as e:
print(f"ZeroDivisionError: {e}") # division by zero

try:
result = 10 % 0
except ZeroDivisionError as e:
print(f"Modulo zero: {e}") # integer division or modulo by zero

# OverflowError: result exceeds float range
import math
try:
result = math.exp(1000)
except OverflowError as e:
print(f"OverflowError: {e}") # math range error

# Note: Python ints never overflow - they grow arbitrarily large
# OverflowError only affects floats and C-level integer operations
big = 2 ** 10000 # This works - Python int, no overflow
print(f"Python int digits: {len(str(big))}") # 3011

# Catching the parent catches both
try:
x = 1.0 / 0.0
except ArithmeticError as e:
print(f"Caught via parent: {type(e).__name__}: {e}")
# ZeroDivisionError: float division by zero

:::note FloatingPointError is Rarely Raised FloatingPointError requires the floating-point trap signal to be enabled via fpectl (removed in Python 3.11) or system-level configuration. In practice, floating-point errors produce inf or nan instead of raising. Test for these with math.isinf() and math.isnan(). :::

Part 3 - LookupError and Its Subclasses

ExceptionRaised When
IndexErrorSequence index is out of range
KeyErrorDict key not found

Both inherit from LookupError; catching LookupError handles both.

Both mean "you asked for something that does not exist by index or key." Catching LookupError handles both:

def safe_get(container, key):
"""Get a value by key/index from any container."""
try:
return container[key]
except LookupError as e:
# Handles both list[out_of_range] and dict[missing_key]
print(f"{type(e).__name__}: {e}")
return None

safe_get([1, 2, 3], 10) # IndexError: list index out of range
safe_get({"a": 1}, "b") # KeyError: 'b'

IndexError in Detail

items = [10, 20, 30]

# Negative indices work; out of range raises IndexError
print(items[-1]) # 30 ← valid negative index
print(items[3]) # IndexError: list index out of range

# In NumPy, the error message is more specific
import numpy as np
arr = np.array([[1, 2], [3, 4]])
arr[5, 0] # IndexError: index 5 is out of bounds for axis 0 with size 2

KeyError in Detail

config = {"host": "localhost", "port": 5432}

# Direct access raises KeyError for missing keys
config["database"] # KeyError: 'database'

# KeyError shows the key in quotes for strings
try:
val = config["database"]
except KeyError as e:
print(repr(e)) # KeyError('database')
print(e.args[0]) # database ← the key itself

# Use .get() to avoid KeyError when a default is acceptable
host = config.get("host", "localhost") # 'localhost'
db = config.get("database", "myapp_db") # 'myapp_db' - no exception

Part 4 - OSError and the File/Network Hierarchy

OSError is Python's unified exception for operating system errors. Before Python 3.3, there were separate names (IOError, EnvironmentError, WindowsError) - these are now all aliases for OSError.

ExceptionRaised When
BlockingIOErrorNon-blocking operation would block
ChildProcessErrorChild process operation failure
ConnectionErrorBase for connection-related errors
BrokenPipeErrorWrite to a closed pipe (subclass of ConnectionError)
ConnectionAbortedErrorConnection aborted by peer (subclass of ConnectionError)
ConnectionRefusedErrorNothing listening on that port (subclass of ConnectionError)
ConnectionResetErrorConnection reset by peer (subclass of ConnectionError)
FileExistsErrorCreating a file that already exists
FileNotFoundErrorFile or directory does not exist
InterruptedErrorSystem call interrupted by signal
IsADirectoryErrorExpected a file, got a directory
NotADirectoryErrorExpected a directory, got a file
PermissionErrorInsufficient permissions
ProcessLookupErrorProcess does not exist
TimeoutErrorOperation timed out

All are subclasses of OSError (aliases: IOError, EnvironmentError).

The errno Attribute

Every OSError instance carries the OS-level error code:

import errno

try:
open("/root/secret.txt")
except OSError as e:
print(f"Exception type: {type(e).__name__}") # PermissionError
print(f"errno: {e.errno}") # 13
print(f"strerror: {e.strerror}") # Permission denied
print(f"filename: {e.filename}") # /root/secret.txt

# Compare against named constants
if e.errno == errno.ENOENT:
print("File does not exist")
elif e.errno == errno.EACCES:
print("Permission denied")

Catching OSError Subclasses

def read_file_content(path):
"""Read a file with specific handling for different OS errors."""
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
# Recoverable - the file might be created later
return None
except PermissionError:
# Actionable - tell the user what to do
raise PermissionError(
f"Cannot read {path!r}. Check file permissions: ls -la {path!r}"
)
except IsADirectoryError:
# Programmer error - wrong argument type
raise ValueError(f"Expected a file path, got a directory: {path!r}")
except OSError as e:
# Everything else - re-raise with context
raise RuntimeError(f"Unexpected OS error reading {path!r}: {e}") from e

Network Errors Are OSError Too

import socket

try:
s = socket.create_connection(("localhost", 99999), timeout=1)
except ConnectionRefusedError as e:
print("Nothing listening on that port")
except TimeoutError as e:
print("Connection timed out")
except OSError as e:
print(f"Network error: {e}")

# All three are subclasses of OSError
print(issubclass(ConnectionRefusedError, OSError)) # True
print(issubclass(TimeoutError, OSError)) # True

Part 5 - ValueError vs TypeError

These are the two most commonly raised exceptions in library code. The distinction is semantic:

ExceptionSemantic MeaningExample
TypeErrorWrong kind of object - the type itself is wrong regardless of value"You gave me a string where I expected an int"
ValueErrorRight kind of object, wrong value - the type is correct but the specific value is invalid"You gave me an int, but it was -5 and must be positive"
# TypeError examples - wrong type of argument
len(42) # TypeError: object of type 'int' has no len()
"hello" + 5 # TypeError: can only concatenate str (not "int") to str
sorted(None) # TypeError: 'NoneType' object is not iterable

# ValueError examples - right type, wrong value
int("hello") # ValueError: invalid literal for int() with base 10: 'hello'
int("42abc") # ValueError: invalid literal for int() with base 10: '42abc'
[1, 2, 3].index(99) # ValueError: 99 is not in list
math.sqrt(-1) # ValueError: math domain error

# The semantic question to ask yourself when raising:
# "Is the type wrong?" → TypeError
# "Is the value wrong?" → ValueError
def set_temperature(celsius):
"""Set system temperature in Celsius."""
if not isinstance(celsius, (int, float)):
raise TypeError(
f"celsius must be int or float, got {type(celsius).__name__!r}"
)
if celsius < -273.15:
raise ValueError(
f"celsius must be above absolute zero (-273.15), got {celsius}"
)
# ... set the temperature

set_temperature("hot") # TypeError: celsius must be int or float, got 'str'
set_temperature(-300) # ValueError: celsius must be above absolute zero
set_temperature(25) # OK

Part 6 - RuntimeError and Its Subclasses

ExceptionRaised When
NotImplementedErrorAbstract method has not been overridden by a subclass
RecursionErrorMaximum recursion depth exceeded

Both inherit from RuntimeError, which is itself a catch-all for errors that do not fit any other category.

RuntimeError

A catch-all for errors that do not fit neatly into another category:

# RuntimeError used when no more specific exception fits
import threading

def thread_only_function():
if threading.current_thread() is threading.main_thread():
raise RuntimeError(
"This function must not be called from the main thread"
)

# Django raises RuntimeError for misconfiguration
# asyncio raises RuntimeError for event loop issues
try:
import asyncio
asyncio.get_event_loop().run_until_complete(None)
except RuntimeError as e:
print(f"Event loop error: {e}")

NotImplementedError

Marks methods that subclasses must override - the Python equivalent of abstract methods:

class DataProcessor:
"""Base class for all data processors."""

def process(self, data):
"""Process data - subclasses must implement this."""
raise NotImplementedError(
f"{type(self).__name__} must implement process()"
)

def validate(self, data):
"""Validate input before processing."""
raise NotImplementedError(
f"{type(self).__name__} must implement validate()"
)

class CSVProcessor(DataProcessor):
def process(self, data):
return data.strip().split(",")

def validate(self, data):
return isinstance(data, str) and "," in data

proc = DataProcessor()
proc.process("test") # NotImplementedError: DataProcessor must implement process()

:::note NotImplementedError vs NotImplemented Do not confuse NotImplementedError (an exception) with NotImplemented (a singleton constant). NotImplemented is returned from __eq__, __add__, etc. to tell Python "I don't know how to handle this, try the other operand." It is not raised. :::

RecursionError

import sys

print(sys.getrecursionlimit()) # Default: 1000

def infinite_recursion(n=0):
return infinite_recursion(n + 1)

try:
infinite_recursion()
except RecursionError as e:
print(f"RecursionError: {e}")
# maximum recursion depth exceeded

# Temporarily increase the limit for deep algorithms
sys.setrecursionlimit(5000)
# But: very deep recursion risks a C stack overflow (segfault)
# Better to rewrite recursive algorithms iteratively for production

Part 7 - StopIteration and the Iterator Protocol

StopIteration is the signal that an iterator is exhausted. It is what for loops and generator functions are built on:

# Manual iteration - what the for loop does automatically
items = [10, 20, 30]
iterator = iter(items)

print(next(iterator)) # 10
print(next(iterator)) # 20
print(next(iterator)) # 30

try:
next(iterator) # StopIteration - no more items
except StopIteration:
print("Iterator exhausted")

StopIteration in Generators

def countdown(n):
"""Generator that counts down from n to 1."""
while n > 0:
yield n
n -= 1
# Falling off the end raises StopIteration automatically

gen = countdown(3)
print(next(gen)) # 3
print(next(gen)) # 2
print(next(gen)) # 1
try:
print(next(gen)) # StopIteration
except StopIteration:
print("Generator done")

:::warning Do NOT Raise StopIteration Inside a Generator (Python 3.7+) In Python 3.7+, raising StopIteration inside a generator body is converted to a RuntimeError. Use return instead to end a generator:

# Bad - raises RuntimeError in Python 3.7+
def bad_gen():
yield 1
raise StopIteration # RuntimeError!

# Correct - use return
def good_gen():
yield 1
return # Cleanly ends the generator

:::

Part 8 - ImportError and ModuleNotFoundError

ExceptionRaised When
ImportErrorThe module exists but something inside it failed to import
ModuleNotFoundErrorThe module does not exist at all (subclass of ImportError)
# ModuleNotFoundError: the module does not exist
try:
import nonexistent_module
except ModuleNotFoundError as e:
print(f"Module not found: {e}") # No module named 'nonexistent_module'

# ImportError: the module exists but something inside it failed to import
try:
from os.path import nonexistent_function
except ImportError as e:
print(f"Import error: {e}") # cannot import name 'nonexistent_function'

# Catching the parent catches both
try:
import nonexistent
except ImportError:
print("Import failed (covers ModuleNotFoundError too)")

# Practical use: optional dependencies
try:
import numpy as np
HAS_NUMPY = True
except ModuleNotFoundError:
HAS_NUMPY = False

def process_array(data):
if not HAS_NUMPY:
raise RuntimeError(
"numpy is required for array processing. "
"Install it with: pip install numpy"
)
return np.array(data)

Part 9 - The Warning Hierarchy

Warnings are not exceptions in the usual sense - they do not halt execution by default. They use the same class hierarchy but are issued via warnings.warn():

Warning ClassUse When
DeprecationWarningFeature will be removed; shown to developers
PendingDeprecationWarningWill be deprecated in the future
RuntimeWarningDubious runtime behavior
SyntaxWarningDubious syntax
ResourceWarningUnclosed resources (files, sockets)
FutureWarningBehavior will change in the future
ImportWarningImport system oddities
UnicodeWarningUnicode encoding issues
BytesWarningbytes/str comparison issues
UserWarningGeneric user-defined warnings
import warnings

# Issue a deprecation warning
def old_function(data):
warnings.warn(
"old_function() is deprecated. Use new_function() instead.",
DeprecationWarning,
stacklevel=2 # Points to the caller, not this function
)
return new_function(data)

# Issue a user warning
def process(data, mode="fast"):
if mode == "slow":
warnings.warn(
"mode='slow' is significantly slower for large datasets. "
"Consider mode='fast' unless you need exact results.",
UserWarning,
stacklevel=2
)

Treating Warnings as Errors

In tests and CI, you often want warnings to be errors:

import warnings

# Treat all DeprecationWarnings as errors in this block
with warnings.catch_warnings():
warnings.simplefilter("error", DeprecationWarning)
try:
old_function(data) # Raises DeprecationWarning as an exception
except DeprecationWarning as e:
print(f"Caught deprecated call: {e}")

Or in pytest:

# pytest.ini
[pytest]
filterwarnings =
error::DeprecationWarning
error::PendingDeprecationWarning

Part 10 - The Decision Framework: Which Exception to Raise

When writing a function, use this decision tree to choose the right exception:

SituationException to Raise
Argument is the wrong typeTypeError("expected str, got int")
Argument is the right type but wrong valueValueError("must be positive, got -5")
A key or index is missing from a containerKeyError(key) or IndexError
A file, directory, or network resource is missingFileNotFoundError, ConnectionRefusedError, etc.
A required attribute or method is missing on an objectAttributeError
A name is not defined in the current scopeNameError (prefer to catch, not raise, this one)
A math operation errorZeroDivisionError, OverflowError, or ValueError
An abstract method that must be overriddenNotImplementedError
Nothing else fits (built-in code)RuntimeError (last resort)
Nothing else fits (library code)Define a custom exception (preferred)
def create_user(name, age, email):
"""Create a user record with validated inputs."""
# Type checks → TypeError
if not isinstance(name, str):
raise TypeError(f"name must be str, got {type(name).__name__}")
if not isinstance(age, int):
raise TypeError(f"age must be int, got {type(age).__name__}")

# Value checks → ValueError
if not name.strip():
raise ValueError("name cannot be empty")
if age < 0:
raise ValueError(f"age must be non-negative, got {age}")
if age > 150:
raise ValueError(f"age {age} is implausibly large (max 150)")
if "@" not in email:
raise ValueError(f"email must contain '@', got {email!r}")

return {"name": name.strip(), "age": age, "email": email.lower()}

Interview Questions

Q1: Why do SystemExit, KeyboardInterrupt, and GeneratorExit inherit from BaseException instead of Exception?

Answer: These three are not program errors - they are termination signals from the runtime or the user. SystemExit is raised by sys.exit() to request process termination. KeyboardInterrupt is raised when the user presses Ctrl+C. GeneratorExit is raised inside a generator when it is closed. By placing them under BaseException rather than Exception, Python allows code to write except Exception: to catch all real program errors while still letting these three propagate naturally. If they were under Exception, a catch-all error handler would accidentally suppress Ctrl+C and sys.exit() calls, making programs un-interruptible.

Q2: What is the difference between IndexError and KeyError, and what do they have in common?

Answer: Both are subclasses of LookupError - they both signal that a lookup by position (index) or name (key) failed because the requested item does not exist. IndexError is raised by sequences (lists, tuples, strings) when an integer index is out of range: [1, 2, 3][10]. KeyError is raised by mappings (dicts) when a key is not present: {}["missing"]. Because they share a parent class (LookupError), you can catch both with except LookupError: when you want to handle "item not found" generically regardless of whether the container is a sequence or a mapping.

Q3: What is the semantic difference between ValueError and TypeError?

Answer: TypeError signals that an argument is the wrong kind (type) of object - the operation cannot work regardless of the value. ValueError signals that an argument is the right kind (type) of object, but the specific value is invalid for the operation. For example: passing a string to sorted(key=...) where a callable is expected → TypeError (wrong type). Passing math.sqrt(-1)ValueError (correct type - float - but invalid value for the domain). The distinction matters because callers handle them differently: TypeError usually indicates a programming mistake, while ValueError often indicates bad input that the user can correct.

Q4: How does StopIteration drive Python's for loop?

Answer: Python's for statement is syntactic sugar over a while loop that calls next() repeatedly. The runtime calls iter() on the iterable to get an iterator, then calls next() on the iterator in a loop. next() raises StopIteration when there are no more items. The for loop catches StopIteration internally and uses it as the signal to break. This means any object that implements __iter__ returning self and __next__ raising StopIteration when exhausted can be iterated with a for loop - including custom classes, generators, and file objects.

Q5: If you write except OSError:, which specific exceptions will it catch?

Answer: It will catch OSError itself plus all of its subclasses: FileNotFoundError, PermissionError, IsADirectoryError, NotADirectoryError, FileExistsError, ConnectionError (and its children: ConnectionRefusedError, ConnectionResetError, BrokenPipeError, ConnectionAbortedError), TimeoutError, BlockingIOError, ChildProcessError, InterruptedError, ProcessLookupError. It will also catch the legacy aliases IOError and EnvironmentError (which are the same class as OSError in Python 3). This is useful when you want to handle any file system or network error uniformly, but you lose the ability to differentiate between a missing file and a permission error.

Q6: When would you catch Exception vs a specific exception type?

Answer: Catch a specific exception type when you know what can go wrong and want to handle that case specifically - this gives the clearest intent and avoids masking unexpected errors. Catch Exception (the broad case) only in a few scenarios: at the boundary of a subsystem where you want to wrap all errors in a domain-specific exception; in top-level request handlers (FastAPI, Django views) where you need to return an HTTP error response instead of crashing; in retry logic where you want to catch any failure; or in cleanup code at the main function level. Even when catching Exception, you should log the full exception and usually re-raise or wrap it - never silently swallow it.

Practice Challenges

Beginner - Classify Exceptions by Branch

Write a function classify_exception(exc) that accepts an exception instance and prints which branches of the hierarchy it belongs to, checking: BaseException, Exception, ArithmeticError, LookupError, OSError, RuntimeError, Warning. Then test it on at least six different exception types.

Solution
def classify_exception(exc):
"""Print which branches of the exception hierarchy this exception belongs to."""
name = type(exc).__name__

branches = {
"BaseException": BaseException,
"Exception": Exception,
"ArithmeticError": ArithmeticError,
"LookupError": LookupError,
"OSError": OSError,
"RuntimeError": RuntimeError,
"ValueError": ValueError,
"TypeError": TypeError,
"Warning": Warning,
}

print(f"\n{name}: {exc!r}")
print(f" MRO: {' -> '.join(c.__name__ for c in type(exc).__mro__)}")
print(" Matches:")
for branch_name, branch_type in branches.items():
if isinstance(exc, branch_type):
print(f" ✓ {branch_name}")

# Test on various exception types
test_exceptions = [
ZeroDivisionError("division by zero"),
FileNotFoundError(2, "No such file or directory"),
KeyError("missing_key"),
ValueError("invalid value"),
TypeError("wrong type"),
RecursionError("maximum recursion depth exceeded"),
ConnectionRefusedError(111, "Connection refused"),
OverflowError("math range error"),
UserWarning("something to note"),
StopIteration(),
]

for exc in test_exceptions:
classify_exception(exc)

Sample output for FileNotFoundError:

FileNotFoundError: FileNotFoundError(2, 'No such file or directory')
MRO: FileNotFoundError -> OSError -> Exception -> BaseException -> object
Matches:
✓ BaseException
✓ Exception
✓ OSError

Sample output for ZeroDivisionError:

ZeroDivisionError: ZeroDivisionError('division by zero')
MRO: ZeroDivisionError -> ArithmeticError -> Exception -> BaseException -> object
Matches:
✓ BaseException
✓ Exception
✓ ArithmeticError

Intermediate - Exception-Aware Data Validator

Write a validate_config(config) function that validates a configuration dictionary for a database connection. The config must have host (str), port (int, 1–65535), database (str, non-empty), and timeout (float or int, greater than 0). Raise the most semantically correct exception for each validation failure. Write tests that verify the right exception type is raised for each invalid case.

Solution
def validate_config(config: dict) -> dict:
"""Validate a database connection configuration.

Args:
config: Dict with keys host, port, database, timeout.

Returns:
The validated config dict (possibly with normalized values).

Raises:
TypeError: If config is not a dict, or a field has the wrong type.
KeyError: If a required key is missing.
ValueError: If a field has the right type but an invalid value.
"""
if not isinstance(config, dict):
raise TypeError(
f"config must be a dict, got {type(config).__name__!r}"
)

# Check all required keys exist - KeyError is semantically correct here
required_keys = ["host", "port", "database", "timeout"]
for key in required_keys:
if key not in config:
raise KeyError(
f"Required config key missing: {key!r}. "
f"Got keys: {list(config.keys())}"
)

host = config["host"]
port = config["port"]
database = config["database"]
timeout = config["timeout"]

# host: must be str
if not isinstance(host, str):
raise TypeError(f"config['host'] must be str, got {type(host).__name__!r}")
if not host.strip():
raise ValueError("config['host'] cannot be empty")

# port: must be int, range 1–65535
if not isinstance(port, int):
raise TypeError(f"config['port'] must be int, got {type(port).__name__!r}")
if not (1 <= port <= 65535):
raise ValueError(
f"config['port'] must be between 1 and 65535, got {port}"
)

# database: must be str, non-empty
if not isinstance(database, str):
raise TypeError(
f"config['database'] must be str, got {type(database).__name__!r}"
)
if not database.strip():
raise ValueError("config['database'] cannot be empty")

# timeout: must be numeric, positive
if not isinstance(timeout, (int, float)):
raise TypeError(
f"config['timeout'] must be int or float, "
f"got {type(timeout).__name__!r}"
)
if timeout <= 0:
raise ValueError(
f"config['timeout'] must be positive, got {timeout}"
)

return {
"host": host.strip(),
"port": port,
"database": database.strip(),
"timeout": float(timeout),
}


# Tests
import traceback

def test(description, config, expected_exc=None):
try:
result = validate_config(config)
if expected_exc:
print(f"FAIL: {description} - expected {expected_exc.__name__}, got result: {result}")
else:
print(f"PASS: {description} - result: {result}")
except Exception as e:
if expected_exc and isinstance(e, expected_exc):
print(f"PASS: {description} - got {type(e).__name__}: {e}")
else:
expected = expected_exc.__name__ if expected_exc else "no exception"
print(f"FAIL: {description} - expected {expected}, got {type(e).__name__}: {e}")

# Valid
test("valid config",
{"host": "localhost", "port": 5432, "database": "mydb", "timeout": 30.0})

# Wrong type for config itself
test("config is not dict", "not_a_dict", expected_exc=TypeError)

# Missing key
test("missing port",
{"host": "localhost", "database": "mydb", "timeout": 30},
expected_exc=KeyError)

# Wrong types
test("port is string",
{"host": "localhost", "port": "5432", "database": "mydb", "timeout": 30},
expected_exc=TypeError)

# Wrong values
test("port out of range",
{"host": "localhost", "port": 99999, "database": "mydb", "timeout": 30},
expected_exc=ValueError)

test("empty host",
{"host": " ", "port": 5432, "database": "mydb", "timeout": 30},
expected_exc=ValueError)

test("negative timeout",
{"host": "localhost", "port": 5432, "database": "mydb", "timeout": -1},
expected_exc=ValueError)

Output:

PASS: valid config - result: {'host': 'localhost', 'port': 5432, 'database': 'mydb', 'timeout': 30.0}
PASS: config is not dict - got TypeError: config must be a dict, got 'str'
PASS: missing port - got KeyError: "Required config key missing: 'port'. Got keys: ['host', 'database', 'timeout']"
PASS: port is string - got TypeError: config['port'] must be int, got 'str'
PASS: port out of range - got ValueError: config['port'] must be between 1 and 65535, got 99999
PASS: empty host - got ValueError: config['host'] cannot be empty
PASS: negative timeout - got ValueError: config['timeout'] must be positive, got -1

Advanced - Exception Hierarchy Explorer

Write a CLI tool explore_hierarchy.py that:

  1. Takes an exception name as a command-line argument
  2. Prints the exception's full MRO (method resolution order) from the exception up to object
  3. Prints all known built-in exceptions that are subclasses of the given exception
  4. Shows which exceptions would be caught by except GivenException: vs except Exception: vs except BaseException:
  5. Handles the case where the given name is not a valid exception
Solution
#!/usr/bin/env python3
"""explore_hierarchy.py - Explore Python's exception hierarchy.

Usage: python explore_hierarchy.py OSError
"""
import sys
import builtins
import inspect


def get_all_builtin_exceptions():
"""Return all built-in exception classes."""
result = []
for name in dir(builtins):
obj = getattr(builtins, name)
if (inspect.isclass(obj)
and issubclass(obj, BaseException)
and obj is not object):
result.append(obj)
return result


def get_subclasses(exc_class, all_exceptions):
"""Return all exceptions that are subclasses of exc_class."""
return [
e for e in all_exceptions
if issubclass(e, exc_class) and e is not exc_class
]


def explore(exc_name: str):
# Resolve the name to a class
exc_class = getattr(builtins, exc_name, None)
if exc_class is None:
print(f"Error: {exc_name!r} is not a built-in exception name.")
print("Examples: ValueError, OSError, FileNotFoundError, Exception")
return 1

if not (inspect.isclass(exc_class) and issubclass(exc_class, BaseException)):
print(f"Error: {exc_name!r} is not an exception class (it is {type(exc_class).__name__})")
return 1

all_exceptions = get_all_builtin_exceptions()

print(f"\n{'='*60}")
print(f"Exception: {exc_name}")
print(f"{'='*60}")

# Method Resolution Order (parents)
print("\nInheritance chain (bottom → top):")
mro = [c for c in exc_class.__mro__ if c is not object]
for i, cls in enumerate(mro):
prefix = " " + " " * i
marker = "← you are here" if cls is exc_class else ""
print(f" {prefix}{cls.__name__} {marker}")
print(" " + " " * len(mro) + "object")

# Subclasses
subclasses = get_subclasses(exc_class, all_exceptions)
if subclasses:
print(f"\nSubclasses ({len(subclasses)} total):")
# Organize into direct vs indirect
direct = [s for s in subclasses if exc_class in s.__bases__]
indirect = [s for s in subclasses if exc_class not in s.__bases__]
for s in sorted(direct, key=lambda x: x.__name__):
print(f" ├── {s.__name__}")
for s in sorted(indirect, key=lambda x: x.__name__):
print(f" │ └── {s.__name__} (indirect)")
else:
print("\nSubclasses: none (this is a leaf exception)")

# Catching analysis
print(f"\nCatching analysis:")
caught_by_specific = [exc_class] + subclasses
not_exc = not issubclass(exc_class, Exception)

print(f" 'except {exc_name}:' catches: {len(caught_by_specific)} exceptions")
print(f" 'except Exception:' {'does NOT catch' if not_exc else 'catches'} {exc_name}")
print(f" 'except BaseException:' always catches {exc_name}")

if not issubclass(exc_class, Exception):
print(f"\n NOTE: {exc_name} is NOT under Exception.")
print(f" It is a direct child of BaseException.")
print(f" 'except Exception:' will NOT catch it.")

return 0


if __name__ == "__main__":
if len(sys.argv) != 2:
print(f"Usage: python {sys.argv[0]} <ExceptionName>")
print(f"Examples: ValueError, OSError, Exception, BaseException")
sys.exit(1)
sys.exit(explore(sys.argv[1]))

Running python explore_hierarchy.py OSError:

============================================================
Exception: OSError
============================================================

Inheritance chain (bottom → top):
OSError ← you are here
Exception
BaseException

Subclasses (14 total):
├── BlockingIOError
├── ChildProcessError
├── ConnectionError
│ └── BrokenPipeError (indirect)
│ └── ConnectionAbortedError (indirect)
│ └── ConnectionRefusedError (indirect)
│ └── ConnectionResetError (indirect)
├── FileExistsError
├── FileNotFoundError
├── InterruptedError
├── IsADirectoryError
├── NotADirectoryError
├── PermissionError
├── ProcessLookupError
├── TimeoutError

Catching analysis:
'except OSError:' catches: 15 exceptions
'except Exception:' catches OSError
'except BaseException:' always catches OSError

Quick Reference

ExceptionBranchTypical Cause
SystemExitBaseExceptionsys.exit() called
KeyboardInterruptBaseExceptionUser pressed Ctrl+C
GeneratorExitBaseExceptiongenerator.close() called
ZeroDivisionErrorArithmeticErrorDivision or modulo by zero
OverflowErrorArithmeticErrorFloat result too large
IndexErrorLookupErrorSequence index out of range
KeyErrorLookupErrorDict key not found
FileNotFoundErrorOSErrorFile does not exist
PermissionErrorOSErrorInsufficient permissions
ConnectionRefusedErrorOSError → ConnectionErrorNothing listening on port
TimeoutErrorOSErrorOperation timed out
ValueErrorExceptionRight type, wrong value
TypeErrorExceptionWrong type of argument
AttributeErrorExceptionObject has no such attribute
NameErrorExceptionName not defined in scope
NotImplementedErrorRuntimeErrorAbstract method not overridden
RecursionErrorRuntimeErrorMax recursion depth exceeded
StopIterationExceptionIterator exhausted
ImportErrorExceptionImport failed
ModuleNotFoundErrorImportErrorModule does not exist

Key Takeaways

  • Python's exception hierarchy is rooted at BaseException, with SystemExit, KeyboardInterrupt, and GeneratorExit as direct children - these are termination signals, not program errors, and should not be caught without re-raising
  • except SomeName: catches that class and every subclass - catching OSError catches FileNotFoundError, PermissionError, and all network errors
  • ArithmeticError covers math errors; LookupError covers both IndexError and KeyError - catch the parent when you want to handle either uniformly
  • OSError is the unified name for all file system and network errors since Python 3.3; IOError and EnvironmentError are aliases
  • TypeError = wrong type of argument; ValueError = right type, wrong value - this semantic distinction is fundamental to writing correct library code
  • StopIteration is not an error - it is the signal that drives for loops, next(), and generator protocol; never raise it inside a generator body (use return instead)
  • Always catch the most specific exception that makes sense; catching broad classes like Exception loses error context and can mask bugs
© 2026 EngineersOfAI. All rights reserved.