Python Exception Handling Best Practices: A Complete Guide
Introduction
Every Python program encounters errors. Files do not exist. Network requests time out. Users enter unexpected input. APIs return unexpected data.
How you handle these situations determines whether your program crashes silently, crashes loudly with a useful message, or recovers gracefully and continues working.
Python’s exception handling system is powerful and flexible — but it is also easy to use incorrectly. Catching too broadly, swallowing errors silently, and ignoring the else and finally clauses are among the most common mistakes in Python code.
This guide covers:
- How Python’s exception system works
- The full
try/except/else/finallystructure - When to catch exceptions and when to let them propagate
- How to write custom exceptions
- Common anti-patterns and how to fix them
- Real-world error handling strategies
All examples are tested on Python 3.12.
How Python Exceptions Work
When Python encounters an error, it raises an exception — an object that represents what went wrong. If the exception is not handled, it propagates up the call stack until it either reaches a handler or terminates the program with a traceback.
def divide(a, b):
return a / b
def calculate():
return divide(10, 0)
calculate()
Expected output:
Traceback (most recent call last):
File "script.py", line 7, in <module>
calculate()
File "script.py", line 5, in calculate
return divide(10, 0)
File "script.py", line 2, in divide
return a / b
ZeroDivisionError: division by zero
The traceback shows the full call stack — exactly which functions were called in which order. Reading tracebacks from the bottom up is the fastest way to find the source of an error.
The Exception Hierarchy
Python exceptions form a class hierarchy. All exceptions inherit from BaseException. Most user-facing exceptions inherit from Exception:
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── KeyError
├── IndexError
├── FileNotFoundError
├── OSError
├── RuntimeError
├── AttributeError
└── ... (many more)
This hierarchy matters because catching a parent class also catches all its children. Catching Exception catches almost everything. Catching OSError catches FileNotFoundError, PermissionError, and other file-related errors.
import sys
print(ZeroDivisionError.__mro__)
Expected output:
(<class 'ZeroDivisionError'>, <class 'ArithmeticError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)
The Full try/except/else/finally Structure
Python’s exception handling has four clauses, each with a distinct purpose:
try:
# Code that might raise an exception
result = risky_operation()
except ValueError as e:
# Runs if ValueError is raised in the try block
print(f"Invalid value: {e}")
except (TypeError, KeyError) as e:
# Catches multiple exception types
print(f"Type or key error: {e}")
else:
# Runs only if NO exception was raised
print(f"Success: {result}")
finally:
# Always runs, whether or not an exception occurred
cleanup()
Most developers know try and except. Fewer use else and finally correctly.
The else Clause
The else block runs only when the try block completes without raising any exception. This is subtle but important — it separates “code that might fail” from “code that runs on success”:
def read_config(path):
try:
f = open(path)
except FileNotFoundError:
print(f"Config file not found: {path}")
return None
else:
# This only runs if open() succeeded
data = f.read()
f.close()
return data
Without else, you might accidentally catch a FileNotFoundError raised inside the success path, not the open() call.
The finally Clause
The finally block always runs — even if an exception is raised, even if return is called inside try, even if the program is about to crash:
def process_file(path):
f = None
try:
f = open(path)
return f.read()
except OSError as e:
print(f"Error reading file: {e}")
return None
finally:
if f:
f.close() # Always runs
print("File closed")
finally is the right place for cleanup code: closing files, releasing locks, closing database connections, or logging that a function completed.
Catching Specific Exceptions
Always catch the most specific exception type you can. Broad catches hide bugs and make debugging harder.
Too Broad
# Bad — catches everything, including bugs you didn't intend to handle
try:
data = json.loads(user_input)
result = process(data)
save(result)
except Exception:
print("Something went wrong")
If process() has a bug that raises AttributeError, this code silently swallows it. You will never know the bug exists.
Appropriately Specific
import json
try:
data = json.loads(user_input)
except json.JSONDecodeError as e:
print(f"Invalid JSON input: {e}")
return None
# process() and save() errors propagate normally — they are not JSON errors
result = process(data)
save(result)
Only the JSON parsing is inside the try block. Other errors propagate and surface as real errors.
Catching Multiple Types
When you genuinely need to handle multiple exception types the same way:
try:
value = int(user_input)
except (ValueError, TypeError) as e:
print(f"Could not convert to integer: {e}")
The as Clause — Using the Exception Object
The as clause gives you access to the exception object itself:
try:
result = 10 / 0
except ZeroDivisionError as e:
print(type(e))
print(str(e))
print(e.args)
Expected output:
<class 'ZeroDivisionError'>
division by zero
('division by zero',)
The exception object contains the error message and any additional context. For file errors, it includes the filename and OS error code:
try:
open("nonexistent.txt")
except FileNotFoundError as e:
print(e.filename)
print(e.strerror)
print(e.errno)
Expected output:
nonexistent.txt
No such file or directory
2
Raising Exceptions
You can raise exceptions explicitly using raise:
def set_age(age):
if not isinstance(age, int):
raise TypeError(f"Age must be an integer, got {type(age).__name__}")
if age < 0 or age > 150:
raise ValueError(f"Age must be between 0 and 150, got {age}")
return age
Re-raising Exceptions
Inside an except block, bare raise re-raises the current exception without losing the original traceback:
def load_data(path):
try:
with open(path) as f:
return json.load(f)
except json.JSONDecodeError:
print(f"Warning: {path} contains invalid JSON")
raise # Re-raises the same JSONDecodeError with original traceback
Exception Chaining
When one exception causes another, use raise ... from ... to preserve the chain:
def connect_to_database(url):
try:
return low_level_connect(url)
except ConnectionRefusedError as e:
raise RuntimeError(f"Could not connect to database at {url}") from e
The from e clause preserves the original ConnectionRefusedError as the __cause__ of the new RuntimeError. Both appear in the traceback, making debugging much easier.
Custom Exceptions
For any non-trivial project, define your own exception classes. This makes error handling code more expressive and allows callers to catch your specific errors without catching everything else.
Basic Custom Exception
class ValidationError(Exception):
pass
class DatabaseError(Exception):
pass
def validate_email(email):
if "@" not in email:
raise ValidationError(f"Invalid email address: {email}")
Custom Exception with Extra Data
class APIError(Exception):
def __init__(self, message, status_code, endpoint):
super().__init__(message)
self.status_code = status_code
self.endpoint = endpoint
def fetch_user(user_id):
response = requests.get(f"/api/users/{user_id}")
if response.status_code == 404:
raise APIError(
f"User {user_id} not found",
status_code=404,
endpoint=f"/api/users/{user_id}"
)
return response.json()
try:
user = fetch_user(999)
except APIError as e:
print(f"API error {e.status_code} at {e.endpoint}: {e}")
Exception Hierarchy for a Project
For larger projects, build a hierarchy of custom exceptions:
class AppError(Exception):
"""Base exception for this application."""
pass
class ValidationError(AppError):
"""Raised when input validation fails."""
pass
class DatabaseError(AppError):
"""Raised when a database operation fails."""
pass
class NetworkError(AppError):
"""Raised when a network request fails."""
pass
Callers can catch AppError to handle any application error, or catch ValidationError specifically to handle only validation failures.
Context Managers and with Statements
The with statement is the cleanest way to handle resources that need cleanup — files, network connections, locks, and database transactions. It guarantees cleanup even if an exception occurs:
# Without context manager — error-prone
f = open("data.txt")
try:
data = f.read()
finally:
f.close()
# With context manager — cleaner and safer
with open("data.txt") as f:
data = f.read()
# f is automatically closed here, even if an exception occurred
Most Python objects that need cleanup implement the context manager protocol. Prefer with whenever it is available.
Common Anti-Patterns
Anti-Pattern 1: Catching Exception or BaseException Broadly
# Wrong
try:
do_something()
except Exception:
pass # silently swallow everything
# Better
try:
do_something()
except SpecificError as e:
logger.error(f"Expected error occurred: {e}")
Catching BaseException is almost always wrong — it catches KeyboardInterrupt (Ctrl+C) and SystemExit, preventing the user from stopping your program.
Anti-Pattern 2: Empty except Blocks
# Wrong — you have no idea what went wrong
try:
connect()
except:
pass
# Better — at minimum, log the error
try:
connect()
except Exception as e:
logger.warning(f"Connection failed: {e}")
An empty except block is sometimes called “exception swallowing.” Errors disappear silently and become impossible to debug.
Anti-Pattern 3: Using Exceptions for Control Flow
# Wrong — using exceptions as a normal code path
def get_value(d, key):
try:
return d[key]
except KeyError:
return None
# Better — check first
def get_value(d, key):
return d.get(key) # returns None if key is missing
Exceptions have overhead and make code harder to follow. When Python provides a clean alternative — .get(), hasattr(), os.path.exists() — prefer it.
Anti-Pattern 4: Catching and Re-raising Without Adding Value
# Wrong — this adds nothing
try:
result = compute()
except ValueError as e:
raise ValueError(str(e)) # same exception, loses traceback
# Better — either handle it or let it propagate
result = compute() # no try/except needed if you're not handling it
If you are not actually handling the exception — logging it, transforming it, or recovering from it — do not catch it. Let it propagate to a level that can handle it meaningfully.
Anti-Pattern 5: Too Much Code in try Blocks
# Wrong — which line caused the ValueError?
try:
name = data["name"]
age = int(data["age"])
email = validate_email(data["email"])
user = create_user(name, age, email)
save_to_db(user)
except ValueError:
print("Invalid data")
# Better — narrow the try block to the risky operation
name = data["name"]
try:
age = int(data["age"])
except ValueError:
print(f"Invalid age: {data['age']!r}")
return None
email = validate_email(data["email"])
Narrow try blocks make it clear exactly what you are protecting against.
Real-World Patterns
Retry with Exponential Backoff
import time
import random
def retry(func, max_attempts=3, base_delay=1.0):
for attempt in range(1, max_attempts + 1):
try:
return func()
except Exception as e:
if attempt == max_attempts:
raise
delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.1)
print(f"Attempt {attempt} failed: {e}. Retrying in {delay:.1f}s...")
time.sleep(delay)
Logging Exceptions
import logging
logger = logging.getLogger(__name__)
def process_record(record):
try:
return transform(record)
except ValueError as e:
logger.warning("Skipping invalid record %s: %s", record.id, e)
return None
except Exception:
logger.exception("Unexpected error processing record %s", record.id)
raise
logger.exception() automatically includes the full traceback in the log output. Use it inside except blocks when you want to log and re-raise.
Graceful Degradation
def get_user_preferences(user_id):
try:
return fetch_from_cache(user_id)
except CacheError:
pass # cache miss is acceptable
try:
prefs = fetch_from_database(user_id)
cache.set(user_id, prefs)
return prefs
except DatabaseError as e:
logger.error("Could not fetch preferences: %s", e)
return DEFAULT_PREFERENCES # fall back to defaults
Quick Reference
| Clause | When It Runs |
|---|---|
try | Always — contains the code that might fail |
except | Only when the specified exception is raised |
else | Only when NO exception was raised |
finally | Always — whether or not an exception occurred |
| Pattern | Use When |
|---|---|
| Specific exception type | You know what can go wrong and how to handle it |
| Multiple exception types | Two or more errors need the same handling |
| Custom exception | Your code needs its own error vocabulary |
raise ... from ... | Wrapping a lower-level error in a higher-level one |
Bare raise | Re-raising inside an except block |
with statement | Managing resources that need cleanup |
Wrap-Up
Good exception handling is not about catching every possible error — it is about catching the right errors, at the right level, with the right response.
Three rules cover most situations:
- Catch specifically — name the exact exception type you expect, not
Exceptionor bareexcept - Handle or propagate — either do something useful with the exception, or let it propagate to a level that can
- Use
finallyfor cleanup — or better yet, usewithstatements when available
For a deeper look at how Python’s execution model connects to exception propagation — including how the CPython VM handles the call stack when an exception is raised — see the deep dive on what happens when you run python script.py. For questions or future tutorial ideas, get in touch via the Contact page.