Handling errors correctly is as important as writing correct logic. Every real program encounters unexpected conditions: a file is missing, a network call times out, a user provides invalid input. Python's exception system gives you the tools to handle these situations gracefully, but using those tools well requires deliberate habits.
Beginners often write try/except blocks that catch too much, silence errors, or mix error handling with business logic. This article covers the patterns that separate robust error handling from fragile code, including the bare-except mistake covered in Avoid Common Python Code Smells.
Catch specific exceptions
The most common mistake in Python error handling is catching too broadly. A bare except: catches every exception, including system signals like KeyboardInterrupt. This can prevent a program from shutting down when the user presses Ctrl+C.
Always catch the most specific exception type that you can handle. If you open a file, catch FileNotFoundError. If you parse JSON, catch json.JSONDecodeError. If you make an HTTP request, catch the specific request exception:
import json
import requests
def load_config(filepath):
try:
with open(filepath) as fh:
return json.load(fh)
except FileNotFoundError:
raise RuntimeError(f"Config file not found: {filepath}")
except json.JSONDecodeError:
raise RuntimeError(f"Config file contains invalid JSON: {filepath}")Each except clause handles one known problem. Unknown problems propagate to the caller, who might have more context about how to recover.
When you need a catch-all for unexpected runtime errors, use except Exception rather than bare except:. This still catches most errors but lets KeyboardInterrupt and SystemExit pass through.
Create custom exceptions
Python's built-in exceptions cover common cases, but domain-specific errors deserve their own types. A custom exception makes error handling precise and gives the caller a clear name to catch:
class InsufficientFundsError(Exception):
"""Raised when an account does not have enough funds for a transaction."""
class AccountFrozenError(Exception):
"""Raised when an account is frozen and cannot process transactions."""
def transfer_funds(source, destination, amount):
if source.balance < amount:
raise InsufficientFundsError(
f"Account {source.id} has {source.balance}, needs {amount}"
)
if source.is_frozen:
raise AccountFrozenError(f"Account {source.id} is frozen")
source.withdraw(amount)
destination.deposit(amount)The caller can now handle each error case specifically. A banking application might retry on insufficient funds but notify support on a frozen account. Generic ValueError or RuntimeError would force the caller to parse error message strings, which is fragile.
Keep custom exceptions simple. A descriptive name and a clear message are usually enough. Add extra attributes only when the caller needs programmatic access to specific details:
class ValidationError(Exception):
def __init__(self, message, field=None):
super().__init__(message)
self.field = field
def validate_email(address):
if "@" not in address:
raise ValidationError("Missing @ sign", field="email")Use else and finally correctly
The else clause in a try block runs only when no exception occurred. Use it for code that should execute in the success path but is not part of the guarded operation. This keeps the try block focused on what might fail:
def fetch_user(user_id):
try:
response = requests.get(f"https://api.example.com/users/{user_id}")
response.raise_for_status()
except requests.RequestException as error:
logger.error("Failed to fetch user %s: %s", user_id, error)
return None
else:
return response.json()The finally clause runs whether an exception occurred or not. Use it for cleanup that must happen regardless of success or failure. The classic example is closing a file or releasing a lock:
lock = threading.Lock()
def update_shared_state(key, value):
lock.acquire()
try:
_state[key] = value
finally:
lock.release()The with statement handles this pattern automatically for resources that support the context manager protocol. Prefer with over manual try/finally when the resource supports it, as covered in Python context managers.
Chain exceptions to preserve the cause
When you catch an exception and raise a different one, use raise ... from to preserve the original cause. This creates an exception chain that debugging tools can follow to find the root problem:
def load_user_data(filepath):
try:
with open(filepath) as fh:
return json.load(fh)
except FileNotFoundError as error:
raise RuntimeError(f"User data file missing: {filepath}") from error
except json.JSONDecodeError as error:
raise RuntimeError(f"User data file is corrupt: {filepath}") from errorWithout from, the original error is lost and the traceback only shows the new RuntimeError. With from, the traceback shows both, making debugging much faster.
Avoid swallowing exceptions silently
The worst error-handling pattern is catching an exception and doing nothing. A bare except-pass hides every problem, including typos and logic errors. Never silence an exception without at least logging it:
# Never do this: swallows all errors silently
try:
process_data()
except Exception:
passIf you genuinely expect a certain error and can safely ignore it, catch the specific type and log the occurrence so you can investigate if it happens too often:
import logging
logger = logging.getLogger(__name__)
def safe_delete(filepath):
try:
os.remove(filepath)
except FileNotFoundError:
logger.debug("File already deleted: %s", filepath)Practical error handling checklist
When adding error handling to a function, ask these questions:
- What specific exceptions can this code raise? Catch only those.
- Can I recover from this error, or should I let it propagate?
- Does the caller need a domain-specific exception type to handle this case?
- Is there cleanup that must happen regardless of success or failure?
- Am I preserving the original exception for debugging?
Run through this checklist any time you add a try/except block, and error handling stays a deliberate design choice instead of an afterthought.
Rune AI
Key Insights
- Catch specific exception types, never use bare except.
- Create custom exceptions for domain-specific error conditions.
- Use else for code that runs only when no exception occurred.
- Use finally for cleanup that must happen regardless of success or failure.
- Chain exceptions with raise ... from to preserve the original cause.
Frequently Asked Questions
When should I catch an exception versus let it propagate?
Is it OK to use a bare except clause?
Conclusion
Exception handling is not about preventing crashes. It is about making failure states explicit, recoverable where possible, and debuggable where not. Catch specifically, clean up with finally, and never silence an error without a good reason.
More in this topic
How to Test Python Code
Learn what testing means in Python, the core ideas behind automated tests, and the built-in and third-party tools that make testing practical.
Write Your First Python Unit Test
Write and run your first Python unit test step by step, using unittest to check a small function and understand the test output.
The Factory Pattern in Python
Learn how to use the factory pattern in Python to encapsulate object creation, making your code more flexible and easier to extend.