Error Handling and Exceptions
Comprehensive guide on Error Handling and Exceptions.
Error Handling and Exceptions
Overview#
Errors are inevitable in any real program — a file might not exist, a
network call might fail, user input might be invalid. Python handles these
situations through exceptions: objects that represent something going
wrong, which can be caught and handled gracefully instead of crashing the
program. This notebook covers the full exception-handling toolkit: try,
except, else, finally, raising exceptions, building custom exception
classes, exception chaining, context managers for cleanup, and practical
debugging patterns.
By the end of this notebook you should be able to write code that fails predictably, communicates clearly what went wrong, and cleans up after itself no matter what.
1. What Is an Exception?#
When Python encounters an error during execution, it raises an exception. If nothing handles it, the program stops and prints a traceback.
Python 3.12 (WASM)numbers = [1, 2, 3]
print(numbers[10])
textTraceback (most recent call last): File "example.py", line 2, in <module> print(numbers[10]) IndexError: list index out of range
The traceback tells you three things: where the error happened (file and
line), what kind of error it is (IndexError), and a message describing
it (list index out of range).
2. The try / except Block#
Wrap risky code in a try block, and handle failures in an except block.
Python 3.12 (WASM)try:
result = 10 / 0
except ZeroDivisionError:
print("You can't divide by zero")
Catching multiple exception types#
Python 3.12 (WASM)try:
value = int(input("Enter a number: "))
result = 10 / value
except ValueError:
print("That wasn't a valid number")
except ZeroDivisionError:
print("Can't divide by zero")
You can also group exception types in a single except using a tuple:
Python 3.12 (WASM)try:
risky_operation()
except (ValueError, TypeError) as e:
print(f"Invalid input: {e}")
Catching the base Exception#
Python 3.12 (WASM)try:
do_something_risky()
except Exception as e:
print(f"Something went wrong: {e}")
Catching bare Exception (or worse, a bare except:) should be done
sparingly — it can hide bugs by silently swallowing errors you didn't
anticipate. Prefer catching the most specific exception type you expect.
3. Common Built-in Exceptions#
| Exception | Raised when |
|---|---|
ValueError | A value has the right type but an inappropriate value (e.g. int("abc")) |
TypeError | An operation is applied to an object of an inappropriate type |
KeyError | A dictionary key is not found |
IndexError | A sequence index is out of range |
AttributeError | An attribute or method doesn't exist on an object |
FileNotFoundError | A file operation targets a file that doesn't exist |
ZeroDivisionError | Division or modulo by zero |
ImportError / ModuleNotFoundError | A module or name cannot be imported |
StopIteration | An iterator has no more items |
RuntimeError | A generic error that doesn't fit other categories |
NotImplementedError | A method is defined but intentionally not implemented |
RecursionError | Maximum recursion depth exceeded |
Python 3.12 (WASM)d = {"a": 1}
try:
print(d["b"])
except KeyError as e:
print(f"Missing key: {e}")
lst = [1, 2, 3]
try:
lst.non_existent_method()
except AttributeError as e:
print(f"Attribute error: {e}")
4. The else Clause#
The else block runs only if the try block completes without raising
an exception. It's useful for separating "code that might fail" from "code
that should only run on success."
Python 3.12 (WASM)try:
number = int("42")
except ValueError:
print("Invalid number")
else:
print(f"Parsed successfully: {number}")
# code here only runs if no exception occurred
5. The finally Clause#
The finally block always runs, whether or not an exception occurred —
even if the exception is not caught. It's the right place for cleanup code
like closing files or releasing resources.
Python 3.12 (WASM)file = None
try:
file = open("data.txt", "r")
contents = file.read()
except FileNotFoundError:
print("File not found")
finally:
if file:
file.close()
print("Cleanup complete")
Full order of execution#
Python 3.12 (WASM)try:
print("try block")
raise ValueError("oops")
except ValueError:
print("except block")
else:
print("else block (skipped, exception occurred)")
finally:
print("finally block (always runs)")
Output:
texttry block except block finally block (always runs)
6. Raising Exceptions#
Use raise to trigger an exception deliberately, typically to enforce
preconditions or signal invalid states.
Python 3.12 (WASM)def set_age(age):
if age < 0:
raise ValueError("Age cannot be negative")
return age
set_age(-5) # raises ValueError: Age cannot be negative
Re-raising an exception#
Inside an except block, a bare raise re-raises the currently handled
exception, preserving its original traceback. This is useful for logging
or partial handling before letting the error propagate further.
Python 3.12 (WASM)try:
process_data()
except ValueError as e:
log_error(e)
raise # re-raise the same exception
7. Exception Chaining#
When one exception is raised while handling another, Python automatically
chains them, showing both in the traceback ("During handling of the above
exception, another exception occurred"). You can also chain explicitly
using raise ... from ....
Python 3.12 (WASM)def load_config():
try:
return int(config_value)
except ValueError as e:
raise RuntimeError("Invalid configuration") from e
Using from e makes the relationship explicit and keeps the original
cause visible for debugging, rather than looking like an unrelated
error.
To suppress chaining entirely (rare, but sometimes desired):
Python 3.12 (WASM)raise RuntimeError("Invalid configuration") from None
8. Custom Exceptions#
For larger applications, defining custom exception classes makes error
handling more precise and self-documenting. Custom exceptions should
inherit from Exception (or a more specific built-in exception).
Python 3.12 (WASM)class InsufficientFundsError(Exception):
"""Raised when an account doesn't have enough balance for a withdrawal."""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw {amount}: balance is only {balance}"
)
class BankAccount:
def __init__(self, balance=0):
self.balance = balance
def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount
account = BankAccount(100)
try:
account.withdraw(150)
except InsufficientFundsError as e:
print(e)
print(f"Short by: {e.amount - e.balance}")
Building an exception hierarchy#
For a larger application, it often helps to define a base exception for your package, then more specific subclasses.
Python 3.12 (WASM)class AppError(Exception):
"""Base exception for all application-specific errors."""
class ValidationError(AppError):
"""Raised when input data fails validation."""
class NotFoundError(AppError):
"""Raised when a requested resource doesn't exist."""
# Callers can catch broadly...
try:
do_something()
except AppError as e:
print(f"Application error: {e}")
# ...or narrowly, depending on need
try:
do_something()
except ValidationError:
print("Please check your input")
except NotFoundError:
print("Resource not found")
9. Context Managers for Guaranteed Cleanup#
The with statement is closely related to exception handling — it
guarantees that cleanup code runs even if an exception occurs inside the
block, without needing an explicit finally.
Python 3.12 (WASM)with open("data.txt", "r") as file:
contents = file.read()
# file is automatically closed here, even if read() raised an exception
You can write your own context manager using a class with __enter__ and
__exit__, or more simply with contextlib.contextmanager:
Python 3.12 (WASM)from contextlib import contextmanager
@contextmanager
def managed_resource(name):
print(f"Acquiring {name}")
try:
yield name
finally:
print(f"Releasing {name}")
with managed_resource("database connection") as res:
print(f"Using {res}")
10. Assertions#
assert checks that a condition is true and raises AssertionError if
not. Assertions are primarily meant for catching programmer errors during
development and debugging — not for validating user input or handling
expected runtime failures, since assertions can be stripped out when
Python runs in optimized mode (python -O).
Python 3.12 (WASM)def calculate_average(numbers):
assert len(numbers) > 0, "List cannot be empty"
return sum(numbers) / len(numbers)
For anything involving external input (user data, file contents, API
responses), raise a proper exception (like ValueError) instead of
relying on assert.
11. Logging Exceptions Properly#
Printing exceptions is fine for small scripts, but real applications
should use the logging module so errors are recorded with context and
severity.
Python 3.12 (WASM)import logging
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
try:
risky_operation()
except Exception:
logger.exception("risky_operation failed")
# logger.exception automatically includes the traceback
12. Common Pitfalls#
Catching too broadly#
Python 3.12 (WASM)# Bad: hides real bugs, including typos and unexpected errors
try:
process()
except:
pass
# Better: catch what you expect, let the rest propagate
try:
process()
except ValueError as e:
handle_invalid_value(e)
Using exceptions for normal control flow#
Exceptions should represent exceptional situations, not routine branching. Overusing them for expected outcomes (like checking whether a key exists) can hurt both readability and performance compared to a simple check.
Python 3.12 (WASM)# Less ideal for something this common:
try:
value = d["key"]
except KeyError:
value = "default"
# More direct:
value = d.get("key", "default")
Swallowing exceptions silently#
Python 3.12 (WASM)# Dangerous: failure disappears without a trace
try:
save_to_database(record)
except Exception:
pass
# Better: at minimum, log it
try:
save_to_database(record)
except Exception:
logger.exception("Failed to save record")
raise
Notes#
- An exception's
__str__(what gets printed withprint(e)) usually comes from the message passed to its constructor. except Exceptionwill not catchSystemExitorKeyboardInterrupt, since those inherit directly fromBaseException, notException— this is intentional, soCtrl+Cand clean process exits aren't accidentally swallowed.- Multiple
exceptclauses are checked top to bottom; put more specific exception types before more general ones. finallyruns even if thetryorexceptblock contains areturnstatement — thefinallyblock always executes before the function actually returns.- As of Python 3.11,
except*(exception groups) allows handling multiple unrelated exceptions raised together, primarily used in concurrent code.
Best Practices Checklist#
- Catch specific exception types rather than bare
except: - Use
finally(or a context manager) for any cleanup that must always happen - Use
elseto separate "success-only" logic from the risky code intry - Raise exceptions with clear, descriptive messages
- Use
raise ... from ...when wrapping one exception in another - Define custom exception classes for domain-specific error conditions
- Never use bare
assertto validate untrusted external input - Log exceptions with full context instead of silently ignoring them
- Avoid using exceptions for routine, expected control flow
- Prefer
withstatements over manual try/finally for resource management wherever possible