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")

Python Playground
Output
Click "Run" to execute your code

The 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:

  • BaseException is the root -- never catch this
  • KeyboardInterrupt and SystemExit are not subclasses of Exception, so except Exception: will not catch them (which is usually what you want)
  • Catching a parent class catches all its children: except LookupError: catches both KeyError and IndexError

Python Playground
Output
Click "Run" to execute your code

Creating 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}")

Python Playground
Output
Click "Run" to execute your code

Using 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 the try block 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")

Python Playground
Output
Click "Run" to execute your code

Re-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

Python Playground
Output
Click "Run" to execute your code

Logging 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

Python Playground
Output
Click "Run" to execute your code

When 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

Python Playground
Output
Click "Run" to execute your code

Common 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)

Python Playground
Output
Click "Run" to execute your code

Graceful 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}"

Python Playground
Output
Click "Run" to execute your code

Summary

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.