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 moreKey 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.