Bad error handling hides bugs. Good error handling makes debugging easy. Here's how to do it right.

The Basics

try:
    result = risky_operation()
except SomeError:
    handle_error()

Simple enough. The problems start with the details.

Never Catch Bare Exceptions

# Bad - catches everything, including bugs
try:
    process_data()
except:
    pass
 
# Also bad
try:
    process_data()
except Exception:
    pass

This catches KeyboardInterrupt, SystemExit, and actual bugs like TypeError. You'll never know something went wrong.

Catch Specific Exceptions

# Good - catch what you expect
try:
    response = httpx.get(url)
    response.raise_for_status()
except httpx.ConnectError:
    logger.error("Could not connect to server")
    return None
except httpx.TimeoutException:
    logger.error("Request timed out")
    return None
except httpx.HTTPStatusError as e:
    logger.error(f"HTTP error: {e.response.status_code}")
    return None

Each exception type gets appropriate handling.

Use Finally for Cleanup

file = open("data.txt")
try:
    process(file)
finally:
    file.close()  # Always runs, even if exception

Or better, use context managers:

with open("data.txt") as file:
    process(file)
# Automatically closed

The Else Clause

Code in else runs only if no exception occurred:

try:
    result = parse(data)
except ParseError:
    logger.error("Failed to parse")
    result = None
else:
    # Only runs if parse succeeded
    logger.info(f"Parsed: {result}")
    save(result)

Keeps the "happy path" separate from error handling.

Create Custom Exceptions

# Define your own hierarchy
class AppError(Exception):
    """Base exception for this application."""
    pass
 
class ValidationError(AppError):
    """Input validation failed."""
    pass
 
class NotFoundError(AppError):
    """Resource not found."""
    pass
 
class AuthenticationError(AppError):
    """Authentication failed."""
    pass

Usage:

def get_user(user_id):
    user = db.find_user(user_id)
    if not user:
        raise NotFoundError(f"User {user_id} not found")
    return user

Add Context to Exceptions

class ValidationError(Exception):
    def __init__(self, field, message):
        self.field = field
        self.message = message
        super().__init__(f"{field}: {message}")
 
# Usage
raise ValidationError("email", "Invalid email format")
 
# Catching
except ValidationError as e:
    print(f"Field '{e.field}' failed: {e.message}")

Re-raising Exceptions

Preserve the original traceback:

try:
    process()
except SomeError:
    logger.error("Processing failed")
    raise  # Re-raises with original traceback

Wrap with context:

try:
    process(user_data)
except ValidationError as e:
    raise ProcessingError(f"Failed to process user") from e

The from e chains the exceptions, showing both in the traceback.

Fail Fast

Don't hide errors early in the process:

# Bad - continues with bad data
def process_users(users):
    results = []
    for user in users:
        try:
            results.append(transform(user))
        except:
            pass  # Silently skip
    return results
 
# Good - fail immediately
def process_users(users):
    return [transform(user) for user in users]
    # If one fails, caller knows immediately

Graceful Degradation

Sometimes you want to continue despite errors:

def fetch_all_data(sources):
    results = []
    errors = []
    
    for source in sources:
        try:
            results.append(fetch(source))
        except FetchError as e:
            errors.append((source, e))
            logger.warning(f"Failed to fetch {source}: {e}")
    
    if errors:
        logger.warning(f"{len(errors)} sources failed")
    
    return results, errors

Return both results and errors. Let the caller decide.

Logging Exceptions

import logging
 
logger = logging.getLogger(__name__)
 
try:
    process()
except ProcessError:
    logger.exception("Processing failed")
    # .exception() includes the full traceback

Use logger.exception() inside except blocks—it automatically includes the traceback.

Validation Patterns

Validate early, fail with clear messages:

def create_user(name, email, age):
    errors = []
    
    if not name:
        errors.append("Name is required")
    if not email or "@" not in email:
        errors.append("Valid email is required")
    if not isinstance(age, int) or age < 0:
        errors.append("Age must be a positive integer")
    
    if errors:
        raise ValidationError(errors)
    
    return User(name=name, email=email, age=age)

API Error Handling

Return appropriate responses:

from fastapi import HTTPException
 
def get_user(user_id: int):
    try:
        return user_service.get(user_id)
    except NotFoundError:
        raise HTTPException(status_code=404, detail="User not found")
    except AuthenticationError:
        raise HTTPException(status_code=401, detail="Not authenticated")
    except ValidationError as e:
        raise HTTPException(status_code=400, detail=str(e))

Testing Exceptions

import pytest
 
def test_raises_on_invalid_input():
    with pytest.raises(ValidationError) as exc_info:
        validate("")
    
    assert "required" in str(exc_info.value)
 
def test_raises_correct_type():
    with pytest.raises(NotFoundError):
        get_user(99999)

My Guidelines

  1. Catch specific exceptions — never bare except
  2. Fail fast — don't hide errors
  3. Add context — make debugging easy
  4. Use custom exceptions — for domain errors
  5. Log at boundaries — API handlers, job runners
  6. Re-raise unknown errors — don't swallow bugs
  7. Test error paths — they matter

Handle errors like they'll happen in production at 3 AM. Because they will.

React to this post: