Exceptions are Python's way of reporting that an operation could not complete normally. A file may not exist, a network request may time out, or user input may not match the expected format. Handling these failures deliberately makes an application more reliable. Catching every error and ignoring it, however, can hide bugs and leave the program in an invalid state.
This guide explains how Python exceptions work, how to raise and catch them, how to design useful error messages, and how to test failure paths.
1. What happens when an exception is raised?
When Python encounters an error condition, it may raise an exception object. If the current function does not handle it, the exception propagates to the caller. This continues up the call stack until a matching handler is found. If no handler catches it, the program reports a traceback and the operation stops.
A traceback is useful because it shows the path through the program that led to the failure. Do not treat it as noise to suppress: during development it is often the most direct clue to the underlying problem.
2. Use try and except for expected failures
A try block contains code that may fail. An except block handles a particular exception type:
raw_age = input("Age: ")
try:
age = int(raw_age)
except ValueError:
print("Enter a whole number.")
else:
print(f"Next year you will be {age + 1}.")
The ValueError handler is narrow: it handles input that cannot be converted to an integer. The else block runs only when the try block completes without raising an exception.
Catch the exception you know how to handle. A broad except Exception can be appropriate at a process boundary for logging and returning a safe error response, but it should not be the default around every function.
3. Use finally for cleanup
The finally block runs whether the try block succeeds or fails. It is useful for cleanup that must happen in either case.
file = None
try:
file = open("notes.txt", encoding="utf-8")
content = file.read()
finally:
if file is not None:
file.close()
For files, the context-manager pattern is clearer and safer:
with open("notes.txt", encoding="utf-8") as file:
content = file.read()
The file is closed automatically when the block exits. Prefer context managers for resources such as files, locks and database connections when the library supports them.
4. Raise exceptions when a function cannot continue
A function should raise an exception when it cannot fulfil its contract.
def calculate_discount(price, percentage):
if price < 0:
raise ValueError("price cannot be negative")
if not 0 <= percentage <= 100:
raise ValueError("percentage must be between 0 and 100")
return price * (1 - percentage / 100)
This makes invalid input explicit and prevents the function from silently returning a misleading result. Choose an exception type that communicates the category of failure.
5. Create custom exception types
Custom exceptions help callers distinguish domain failures from unrelated programming errors.
class InsufficientBalanceError(Exception):
"""Raised when an account cannot cover a withdrawal."""
def withdraw(balance, amount):
if amount <= 0:
raise ValueError("amount must be positive")
if amount > balance:
raise InsufficientBalanceError("not enough balance")
return balance - amount
A caller can handle the domain condition separately:
try:
new_balance = withdraw(500, 700)
except InsufficientBalanceError:
print("The withdrawal cannot be completed.")
For a larger application, organize related exceptions under a common base class. This allows a service boundary to catch a family of expected domain errors without catching every possible exception.
6. Preserve the original cause
When translating a low-level exception into a domain-specific one, preserve the cause:
try:
config = load_configuration()
except OSError as error:
raise RuntimeError("Could not load application configuration") from error
The from error syntax records the original exception as the cause. This is valuable for debugging because the user-facing message can remain meaningful while the traceback preserves technical detail.
Avoid returning raw stack traces to end users. Log enough context for maintainers, while keeping secrets, tokens and personal data out of logs.
7. Catch errors at the right boundary
A lower-level function should usually raise a useful exception rather than deciding how the entire application responds. The API route, command-line interface or job runner can translate the error into the appropriate response.
For example:
- A command-line tool may print a clear message and exit with a non-zero status.
- A web API may return a suitable HTTP status and a safe error body.
- A background worker may retry a temporary failure or move a message to a dead-letter queue.
- A library may raise a documented exception so its caller can decide what to do.
Do not convert every error into a success-shaped return value. A missing database record, a network timeout and a programming bug require different handling.
8. Retry only failures that may recover
Retries can help with temporary network errors, rate limits or service unavailability. They do not fix invalid input or a bug in application logic.
A safe retry strategy should use a bounded attempt count, backoff, jitter where appropriate, and a total deadline. For operations that create or charge something, consider idempotency so a retry does not duplicate the side effect.
Do not retry every exception indiscriminately. Classify failures based on the service contract and the operation being performed.
9. Common mistakes
| Mistake | Why it is dangerous | Better practice |
|---|---|---|
| Catching every exception and doing nothing | Hides failures and corrupts assumptions | Catch expected types and handle them explicitly |
| Using exceptions for ordinary branching | Makes normal flow difficult to read | Validate expected conditions directly |
| Returning None for every error | Makes failure indistinguishable from a valid empty result | Use documented return types or raise meaningful errors |
| Losing the original exception | Removes the root cause from diagnostics | Chain exceptions with from |
| Exposing tracebacks to users | Can reveal paths and sensitive details | Return safe messages and log protected diagnostics |
| Retrying permanent failures | Wastes time and can repeat side effects | Retry only classified transient failures |
| Forgetting cleanup | Leaks files, locks or connections | Use context managers and finally where needed |
10. Test both success and failure paths
A reliable test suite checks what should happen when input is invalid or a dependency fails.
import pytest
def test_negative_price_is_rejected():
with pytest.raises(ValueError):
calculate_discount(-10, 10)
def test_invalid_percentage_is_rejected():
with pytest.raises(ValueError):
calculate_discount(100, 120)
Also test successful results, boundary values, missing files, timeouts and error translation at the application boundary. Avoid tests that merely assert that “some exception” occurred when the exact exception type is part of the function contract.
Frequently asked questions
Should I use bare except?
Usually no. A bare except catches exceptions such as KeyboardInterrupt and SystemExit that applications often should allow to propagate. Catch a specific exception type or, at a deliberate top-level boundary, a documented broad class.
Is finally always executed?
The finally block normally runs as control leaves the try statement, including when an exception is raised. Process termination or abrupt interpreter shutdown can prevent normal cleanup, so do not use it as a guarantee for external durability.
When should I create a custom exception?
Create one when callers need to distinguish a meaningful domain or library failure. Do not create a large hierarchy for errors that no caller handles differently.
Final checklist
- Catch only errors you can handle.
- Use specific exception types.
- Raise clear errors when a function's contract cannot be met.
- Preserve causes when translating exceptions.
- Use context managers for resource cleanup.
- Retry only transient failures with bounded policies.
- Keep user-facing messages safe and logs useful.
- Test both successful and failing paths.
Good exception handling does not mean preventing every error from appearing. It means making failures understandable, preserving the information needed to fix them, and ensuring the application responds safely.