Chapter 11: Error Handling and Exceptions
No program runs perfectly all the time. Files may be missing, network connections can fail, and user input often contains surprises. When such problems occur, Python signals them through exceptions. These are special runtime events that interrupt normal flow and carry information about what went wrong.
Effective handling of exceptions allows a program to recover gracefully instead of crashing. Python’s design makes this process explicit and readable. Rather than hiding errors, it encourages you to anticipate and manage them with clear structures such as try, except, and finally.
This chapter explores how Python reports and handles errors, how to raise and define custom exceptions, and how to use exception handling as part of robust, maintainable program design.
Built-in Exception Hierarchy
Python represents all errors as objects derived from the built-in BaseException class. This creates a structured hierarchy where related exceptions share common ancestry, allowing developers to catch broad categories of problems or handle specific ones precisely.
At the top of the hierarchy is BaseException, from which the standard Exception class inherits. Most user-defined and built-in errors derive from Exception, while a few special cases such as SystemExit, KeyboardInterrupt, and GeneratorExit descend directly from BaseException. These special cases exist to signal interpreter-level events rather than program bugs.
The hierarchy begins roughly as follows:
re class="tight">BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── ImportError
├── AttributeError
├── TypeError
├── ValueError
├── FileNotFoundError
├── OSError
└── RuntimeError
Because all exceptions inherit from a shared base, they can be caught either individually or collectively. For example, catching Exception will intercept most runtime errors, while catching BaseException will also include system-related interruptions, something that is rarely desirable in ordinary code.
try / except / else / finally Structure
Python’s primary mechanism for handling runtime errors is the try statement. It lets you define a block of code to attempt, and one or more accompanying blocks that specify what should happen if something goes wrong. This keeps error handling separate from normal logic, improving clarity and flow.
The full structure can include four parts:
try:
# code that might raise an exception
except SomeError:
# code to handle the error
else:
# code that runs only if no exception occurred
finally:
# code that runs no matter what happens
Only the try and at least one except clause are required. The else clause is optional and runs when no exceptions are raised, which makes it a good place for actions that depend on successful completion of the try block. The finally clause always executes, whether or not an error occurred, and is typically used for cleanup actions such as closing files or releasing resources.
Here is a simple example:
try:
f = open("data.txt")
content = f.read()
except FileNotFoundError:
print("File not found.")
else:
print("File read successfully.")
finally:
f.close()
If the file data.txt does not exist, the except block runs and the program continues normally. If the file is opened and read without issue, the else block runs instead. In either case, the finally block ensures that the file is properly closed. This structure encourages predictable, fault-tolerant programs that can handle unexpected conditions gracefully.
Raising Exceptions
While many exceptions occur automatically during runtime, you can also generate them yourself when your program encounters a situation that warrants stopping normal flow. This is done with the raise statement. It allows you to signal that something has gone wrong and to communicate what kind of problem it is.
The simplest form raises a specific built-in or custom exception class:
raise ValueError("Invalid input format")
When Python encounters this statement, it creates a ValueError object carrying the message provided, and then searches upward through the call stack for a matching except block. If no handler is found, the program terminates and reports the error.
Any subclass of BaseException can be raised, but conventionally you use Exception or its descendants. You can also raise an existing exception instance that was created earlier:
e = RuntimeError("Something went wrong")
raise e
If you need to re-raise the current exception from within an except block, simply use raise with no argument. This preserves the original traceback and type information:
try:
process()
except ValueError:
log_error()
raise # re-raise the same ValueError
Raising exceptions is an important part of designing reliable functions and modules. It allows you to enforce invariants, flag invalid states, or delegate responsibility for recovery to higher levels of your program. When used consistently, exceptions become a clear and explicit communication mechanism between components.
Custom Exception Classes
Python allows you to define your own exception types, giving structure and meaning to the specific errors your program might encounter. Custom exceptions make debugging easier and provide a clearer interface for anyone using your code, because each error type can convey precise intent rather than relying on generic messages.
To define a custom exception, create a new class that inherits from Exception (or from one of its subclasses if you want related behaviour). A simple example looks like this:
class DataFormatError(Exception):
"""Raised when data is not in the expected format."""
pass
This class behaves like any other exception but can be caught independently, making it easier to distinguish between different error types:
try:
raise DataFormatError("Corrupted input file")
except DataFormatError as e:
print("Data problem:", e)
except Exception as e:
print("Other error:", e)
You can also extend custom exceptions with additional attributes to carry more context, such as file names or error codes:
class ConfigurationError(Exception):
def __init__(self, message, filename):
super().__init__(message)
self.filename = filename
try:
raise ConfigurationError("Missing section", "settings.ini")
except ConfigurationError as e:
print(f"{e.filename}: {e}")
By grouping related custom exceptions under a shared base class, you can build entire hierarchies that mirror your program’s structure. This helps catch classes of errors together while preserving the ability to handle individual cases when needed.
Defensive Coding Patterns
Defensive coding complements exception handling by preventing many failures and by making the unavoidable ones easier to diagnose and recover from.
Good error handling begins before exceptions occur. Defensive patterns reduce surprising states, keep tracebacks meaningful, and make failures recoverable. These practices work with Python’s exception model to produce code that is clear and resilient.
Validate Early, Fail Fast
Check inputs at boundaries, and raise descriptive exceptions when assumptions are violated. Early failure keeps bugs close to their cause and prevents corrupted state from spreading.
def read_port(env):
port = env.get("PORT")
if port is None:
raise KeyError("PORT is required")
try:
return int(port)
except ValueError as e:
raise ValueError("PORT must be an integer") from e
EAFP over LBYL
Easier to ask forgiveness than permission is common in Python. Prefer trying the operation and catching specific failures, rather than checking every precondition first, when that leads to simpler code.
# EAFP - Easier to ask Forgiveness than Permission
try:
value = mapping[key]
except KeyError:
value = default
# LBYL - Look Before You Leap
if key in mapping:
value = mapping[key]
else:
value = default
Catch Narrowly, Then Generalize
Order handlers from specific to general, and avoid bare except:. Handle only what you can recover from. Let unrelated errors propagate.
try:
do_work()
except (TimeoutError, ConnectionError) as e:
recover(e)
except Exception:
# unexpected, bubble up after logging
raise
Use else and finally Intentionally
Place success-only actions in else. Put cleanup in finally so it runs even if an error occurs.
f = open("report.txt", "w")
try:
write_report(f)
except OSError as e:
log(e)
raise
else:
print("Report written.")
finally:
f.close()
Prefer Context Managers
Context managers encapsulate setup and teardown, which prevents resource leaks and simplifies error paths.
from contextlib import suppress
with open("data.json") as f:
data = f.read()
# ignore a specific, expected error deliberately
with suppress(FileNotFoundError):
os.remove("tempfile.tmp")
Preserve Context with Chaining
Wrap low-level errors in domain-specific ones and keep the original traceback with raise ... from .... This gives both high-level meaning and the root cause.
try:
payload = json.loads(raw)
except json.JSONDecodeError as e:
raise ValueError("Invalid configuration payload") from e
Guard Clauses for Clarity
Return early when preconditions are not met. This reduces nesting and keeps the happy path clear.
def normalize(vec):
if not vec:
return []
length = sum(x * x for x in vec) ** 0.5
if length == 0:
raise ValueError("Zero-length vector")
return [x / length for x in vec]
Do Not Silence Errors Accidentally
Empty handlers hide problems and complicate debugging. If you catch an exception and cannot recover, log and re-raise.
try:
update_index()
except Exception:
logger.exception("Index update failed")
raise
Be Deliberate About Retries
Retries are useful for transient faults such as network hiccups. Apply caps and backoff to avoid tight loops. Only retry idempotent operations.
import time
def fetch_with_retry(get, attempts=3, base=0.2):
for i in range(attempts):
try:
return get()
except (TimeoutError, ConnectionError):
if i == attempts - 1:
raise
time.sleep(base * 2 ** i)
Use assert for Internal Invariants
Assertions document assumptions for developers and may be disabled with optimization flags. Do not use them for user input or runtime validation.
def average(xs):
assert all(isinstance(x, (int, float)) for x in xs)
return sum(xs) / len(xs)
Design Clear Exception APIs
Choose meaningful exception types, document what your functions raise, and keep messages actionable. Group related custom exceptions under a shared base class so callers can handle families of failures cleanly.