Error Handling Best Practices
Good error handling is the difference between a program that crashes mysteriously and one that fails gracefully with clear messages. Python's exception system is powerful, and learning to use it well is essential.
Catch Specific Exceptions
The most important rule of error handling: catch specific exceptions, not broad ones. A bare except: or except Exception: hides bugs by swallowing errors you did not anticipate.
# Bad: catches everything
try:
value = data[key]
except:
value = default
# Bad: still too broad
try:
value = data[key]
except Exception:
value = default
# Good: catches exactly what you expect
try:
value = data[key]
except KeyError:
value = default
When you need to handle multiple exception types, you can catch them in a tuple or use separate except blocks:
try:
result = int(user_input) / divisor
except ValueError:
print("Input must be a number")
except ZeroDivisionError:
print("Cannot divide by zero")
except (TypeError, AttributeError):
print("Invalid data types")
Click "Run" to execute your codeThe Exception Hierarchy
Understanding Python's exception hierarchy helps you catch exceptions at the right level.
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── StopIteration
├── ArithmeticError
│ ├── ZeroDivisionError
│ ├── OverflowError
│ └── FloatingPointError
├── LookupError
│ ├── IndexError
│ └── KeyError
├── OSError
│ ├── FileNotFoundError
│ ├── PermissionError
│ └── TimeoutError
├── TypeError
├── ValueError
├── AttributeError
└── ... many more
Key points:
BaseExceptionis the root -- never catch thisKeyboardInterruptandSystemExitare not subclasses ofException, soexcept Exception:will not catch them (which is usually what you want)- Catching a parent class catches all its children:
except LookupError:catches bothKeyErrorandIndexError
Click "Run" to execute your codeCreating Custom Exception Classes
For any non-trivial application, create a hierarchy of custom exceptions. This is one place where inheritance is the right tool -- exception classes have a natural "is-a" relationship.
# Base exception for your application
class AppError(Exception):
"""Base exception for our application."""
pass
# Specific error categories
class ValidationError(AppError):
"""Raised when input validation fails."""
pass
class NotFoundError(AppError):
"""Raised when a requested resource doesn't exist."""
pass
class AuthenticationError(AppError):
"""Raised when authentication fails."""
pass
class AuthorizationError(AppError):
"""Raised when a user lacks permission."""
pass
With this hierarchy, callers can catch AppError to handle all application errors, or catch specific subclasses for more targeted handling.
try:
user = get_user(user_id)
except NotFoundError:
return {"error": "User not found"}, 404
except AuthenticationError:
return {"error": "Please log in"}, 401
except AppError:
return {"error": "Something went wrong"}, 500
Adding Context to Custom Exceptions
Custom exceptions can carry additional data:
class ValidationError(AppError):
def __init__(self, field, message, value=None):
self.field = field
self.message = message
self.value = value
super().__init__(f"{field}: {message}")
Click "Run" to execute your codeUsing else and finally with Try Blocks
The else and finally clauses add important control flow to try blocks:
else: Runs only if no exception was raised. Put code here that should only execute on success, keeping thetryblock minimal.finally: Runs no matter what, even if an exception occurs or the function returns. Use it for cleanup.
try:
file = open("data.txt")
except FileNotFoundError:
print("File not found")
else:
# Only runs if open() succeeded
data = file.read()
print(f"Read {len(data)} characters")
finally:
# Always runs
print("Cleanup complete")
Click "Run" to execute your codeRe-raising Exceptions
Sometimes you need to catch an exception, do something with it (like logging), and then re-raise it. Python gives you several options.
Bare raise (Preserve Original Traceback)
try:
risky_operation()
except ValueError:
log_error("Something went wrong")
raise # Re-raises the same exception with original traceback
raise ... from (Exception Chaining)
When you catch one exception and raise a different one, use from to chain them. This preserves the context of the original error.
try:
value = int(user_input)
except ValueError as e:
raise ValidationError("age", "must be a number") from e
raise ... from None (Suppress Chaining)
When the original exception is not useful context, suppress it:
try:
return config_dict[key]
except KeyError:
raise ConfigError(f"Missing config key: {key}") from None
Click "Run" to execute your codeLogging Errors Properly
When catching exceptions, log them with full context. The logging module can capture exception tracebacks automatically.
import logging
logger = logging.getLogger(__name__)
try:
result = process_data(data)
except ValueError as e:
# Logs the full traceback
logger.exception("Failed to process data")
raise
# For non-exception logging
logger.error("Operation failed for user %s", user_id)
logger.warning("Retrying after timeout (attempt %d/%d)", attempt, max_retries)
Key practices:
- Use
logger.exception()inside except blocks -- it automatically includes the traceback - Use
logger.error()for errors without exceptions - Include relevant context (user IDs, input values, etc.)
- Never use
print()for error reporting in production code
Click "Run" to execute your codeWhen to Catch vs When to Let Propagate
Not every exception needs to be caught. Here are guidelines for when to catch and when to let exceptions propagate.
Catch when:
- You can meaningfully recover from the error
- You need to translate the exception to a more appropriate type
- You need to log the error and continue
- You are at a boundary (API endpoint, CLI entry point, task runner)
Let propagate when:
- You cannot recover from the error
- The caller is in a better position to handle it
- The exception already has a clear message
- Catching it would just hide bugs
# Good: catch at the boundary, let internals propagate
def api_endpoint(request):
try:
result = process_request(request) # May raise various exceptions
return {"data": result}, 200
except ValidationError as e:
return {"error": str(e)}, 400
except NotFoundError as e:
return {"error": str(e)}, 404
except Exception:
logger.exception("Unexpected error in api_endpoint")
return {"error": "Internal server error"}, 500
Context Managers for Cleanup
Context managers guarantee cleanup code runs, even if an exception occurs. Use them for files, database connections, locks, and any resource that needs cleanup.
Using contextlib
The contextlib module provides utilities for creating context managers without writing a full class.
from contextlib import contextmanager, suppress
# Create a context manager with a generator
@contextmanager
def database_connection(url):
conn = connect(url)
try:
yield conn
finally:
conn.close()
# suppress() is a context manager that silently ignores specified exceptions
from contextlib import suppress
with suppress(FileNotFoundError):
os.remove("temp.txt") # No error if file doesn't exist
Click "Run" to execute your codeCommon Patterns
Retry Logic
Retry operations that may fail temporarily (network requests, file locks, etc.).
import time
def retry(func, max_attempts=3, delay=1.0, exceptions=(Exception,)):
for attempt in range(1, max_attempts + 1):
try:
return func()
except exceptions as e:
if attempt == max_attempts:
raise
print(f"Attempt {attempt} failed: {e}. Retrying in {delay}s...")
time.sleep(delay)
Click "Run" to execute your codeGraceful Degradation
When an operation fails, fall back to a simpler alternative instead of crashing.
def get_user_display_name(user_id):
"""Try multiple sources, falling back gracefully."""
# Try the cache first
try:
return cache.get(user_id)
except CacheError:
pass
# Try the database
try:
user = db.get_user(user_id)
return user.display_name
except DatabaseError:
pass
# Last resort: return a generic name
return f"User #{user_id}"
Click "Run" to execute your codeSummary
| Practice | Description |
|---|---|
| Catch specific exceptions | Never use bare except: |
| Use custom exception hierarchies | Build with inheritance for clean except chains |
Use else with try |
Keep the try block minimal |
Use finally or context managers |
Guarantee cleanup |
Chain exceptions with from |
Preserve error context |
Log with logger.exception() |
Capture full tracebacks |
| Let exceptions propagate | When the caller can handle it better |
| Retry transient failures | With a maximum attempt limit |
| Degrade gracefully | Provide fallbacks instead of crashing |
What's Next?
Once you have solid error handling, learn how to make your code faster with Performance Tips.