Error handling is where good code becomes great code. Here's what I've learned.

The Golden Rule

Only catch exceptions you can handle.

# Bad: catching everything
try:
    result = risky_operation()
except Exception:
    pass  # Now you'll never know what went wrong
 
# Good: catching specific exceptions
try:
    result = risky_operation()
except ConnectionError:
    result = cached_fallback()

If you can't meaningfully handle an exception, let it propagate.

Catch Specific, Not General

# Bad
try:
    data = json.loads(response.text)
    user = data["user"]
    email = user["email"]
except Exception as e:
    logger.error(f"Failed: {e}")
 
# Good
try:
    data = json.loads(response.text)
except json.JSONDecodeError as e:
    logger.error(f"Invalid JSON: {e}")
    raise
 
try:
    email = data["user"]["email"]
except KeyError as e:
    logger.error(f"Missing field: {e}")
    raise ValueError(f"Response missing required field: {e}")

Specific catches tell you exactly what went wrong. Generic catches hide bugs.

Custom Exceptions

Create domain-specific exceptions:

class TaskError(Exception):
    """Base exception for task operations."""
    pass
 
class TaskNotFoundError(TaskError):
    """Raised when a task doesn't exist."""
    def __init__(self, task_id: str):
        self.task_id = task_id
        super().__init__(f"Task not found: {task_id}")
 
class TaskAlreadyCompleteError(TaskError):
    """Raised when trying to complete an already-complete task."""
    pass

Now callers can catch TaskError for any task issue, or specific subclasses for specific handling.

When to Propagate

Let exceptions bubble up when:

  • The caller is better positioned to handle them
  • You're in a library (don't make decisions for the user)
  • The error is unrecoverable at your level
def fetch_user(user_id: str) -> User:
    response = requests.get(f"/users/{user_id}")
    response.raise_for_status()  # Propagate HTTP errors
    return User(**response.json())

The caller decides whether to retry, show an error, or log and continue.

When to Transform

Transform low-level exceptions into domain exceptions:

def save_task(task: Task) -> None:
    try:
        db.execute("INSERT INTO tasks ...", task.to_dict())
    except sqlite3.IntegrityError as e:
        raise TaskAlreadyExistsError(task.id) from e
    except sqlite3.OperationalError as e:
        raise TaskStorageError(f"Database error: {e}") from e

The from e preserves the original exception in the chain. Debuggers and logs show both.

Context Managers for Cleanup

Use try/finally or context managers for cleanup:

# Manual cleanup
file = open("data.txt")
try:
    process(file)
finally:
    file.close()  # Always runs
 
# Better: context manager
with open("data.txt") as file:
    process(file)  # File closes automatically

Context managers are cleaner and harder to mess up.

The else Clause

else runs only if no exception was raised:

try:
    result = parse(data)
except ParseError:
    result = default_value
else:
    # Only runs if parse succeeded
    log_success(result)
finally:
    # Always runs
    cleanup()

Use else to separate "happy path" code from error handling.

Logging Exceptions

Log the full exception, not just the message:

# Bad: loses stack trace
except Exception as e:
    logger.error(f"Error: {e}")
 
# Good: preserves stack trace
except Exception as e:
    logger.exception("Operation failed")  # Logs full traceback
 
# Also good: explicit
except Exception as e:
    logger.error("Operation failed", exc_info=True)

Validation vs Exceptions

Use exceptions for exceptional conditions, not control flow:

# Bad: using exceptions for validation
def get_user(user_id: str) -> User:
    if not user_id:
        raise ValueError("user_id required")
    ...
 
# Better: validate early, return clear errors
def get_user(user_id: str) -> User | None:
    if not user_id:
        return None
    ...
 
# Or use explicit result types
from dataclasses import dataclass
 
@dataclass
class Result[T]:
    value: T | None
    error: str | None

My Patterns

  1. Fail fast. Validate inputs at boundaries. Don't let bad data propagate.

  2. Be specific. Catch KeyError, not Exception.

  3. Add context. Transform exceptions with meaningful messages.

  4. Preserve chains. Use raise ... from e.

  5. Log properly. Use logger.exception() for full tracebacks.

  6. Clean up always. Use finally or context managers.

The goal: when something fails, make it obvious what failed and why.

React to this post: