Handle Python Errors with Exceptions

Learn how to use Python exceptions correctly in real applications, from choosing the right exception type to writing clean error-handling patterns.

7 min read

Handling errors correctly is as important as writing correct logic. Every real program encounters unexpected conditions: a file is missing, a network call times out, a user provides invalid input. Python's exception system gives you the tools to handle these situations gracefully, but using those tools well requires deliberate habits.

Beginners often write try/except blocks that catch too much, silence errors, or mix error handling with business logic. This article covers the patterns that separate robust error handling from fragile code, including the bare-except mistake covered in Avoid Common Python Code Smells.

Catch specific exceptions

The most common mistake in Python error handling is catching too broadly. A bare except: catches every exception, including system signals like KeyboardInterrupt. This can prevent a program from shutting down when the user presses Ctrl+C.

Always catch the most specific exception type that you can handle. If you open a file, catch FileNotFoundError. If you parse JSON, catch json.JSONDecodeError. If you make an HTTP request, catch the specific request exception:

pythonpython
import json
import requests
 
def load_config(filepath):
    try:
        with open(filepath) as fh:
            return json.load(fh)
    except FileNotFoundError:
        raise RuntimeError(f"Config file not found: {filepath}")
    except json.JSONDecodeError:
        raise RuntimeError(f"Config file contains invalid JSON: {filepath}")

Each except clause handles one known problem. Unknown problems propagate to the caller, who might have more context about how to recover.

When you need a catch-all for unexpected runtime errors, use except Exception rather than bare except:. This still catches most errors but lets KeyboardInterrupt and SystemExit pass through.

Create custom exceptions

Python's built-in exceptions cover common cases, but domain-specific errors deserve their own types. A custom exception makes error handling precise and gives the caller a clear name to catch:

pythonpython
class InsufficientFundsError(Exception):
    """Raised when an account does not have enough funds for a transaction."""
 
class AccountFrozenError(Exception):
    """Raised when an account is frozen and cannot process transactions."""
 
 
def transfer_funds(source, destination, amount):
    if source.balance < amount:
        raise InsufficientFundsError(
            f"Account {source.id} has {source.balance}, needs {amount}"
        )
    if source.is_frozen:
        raise AccountFrozenError(f"Account {source.id} is frozen")
    source.withdraw(amount)
    destination.deposit(amount)

The caller can now handle each error case specifically. A banking application might retry on insufficient funds but notify support on a frozen account. Generic ValueError or RuntimeError would force the caller to parse error message strings, which is fragile.

Keep custom exceptions simple. A descriptive name and a clear message are usually enough. Add extra attributes only when the caller needs programmatic access to specific details:

pythonpython
class ValidationError(Exception):
    def __init__(self, message, field=None):
        super().__init__(message)
        self.field = field
 
 
def validate_email(address):
    if "@" not in address:
        raise ValidationError("Missing @ sign", field="email")

Use else and finally correctly

The else clause in a try block runs only when no exception occurred. Use it for code that should execute in the success path but is not part of the guarded operation. This keeps the try block focused on what might fail:

pythonpython
def fetch_user(user_id):
    try:
        response = requests.get(f"https://api.example.com/users/{user_id}")
        response.raise_for_status()
    except requests.RequestException as error:
        logger.error("Failed to fetch user %s: %s", user_id, error)
        return None
    else:
        return response.json()

The finally clause runs whether an exception occurred or not. Use it for cleanup that must happen regardless of success or failure. The classic example is closing a file or releasing a lock:

pythonpython
lock = threading.Lock()
 
def update_shared_state(key, value):
    lock.acquire()
    try:
        _state[key] = value
    finally:
        lock.release()

The with statement handles this pattern automatically for resources that support the context manager protocol. Prefer with over manual try/finally when the resource supports it, as covered in Python context managers.

Chain exceptions to preserve the cause

When you catch an exception and raise a different one, use raise ... from to preserve the original cause. This creates an exception chain that debugging tools can follow to find the root problem:

pythonpython
def load_user_data(filepath):
    try:
        with open(filepath) as fh:
            return json.load(fh)
    except FileNotFoundError as error:
        raise RuntimeError(f"User data file missing: {filepath}") from error
    except json.JSONDecodeError as error:
        raise RuntimeError(f"User data file is corrupt: {filepath}") from error

Without from, the original error is lost and the traceback only shows the new RuntimeError. With from, the traceback shows both, making debugging much faster.

Avoid swallowing exceptions silently

The worst error-handling pattern is catching an exception and doing nothing. A bare except-pass hides every problem, including typos and logic errors. Never silence an exception without at least logging it:

pythonpython
# Never do this: swallows all errors silently
try:
    process_data()
except Exception:
    pass

If you genuinely expect a certain error and can safely ignore it, catch the specific type and log the occurrence so you can investigate if it happens too often:

pythonpython
import logging
 
logger = logging.getLogger(__name__)
 
def safe_delete(filepath):
    try:
        os.remove(filepath)
    except FileNotFoundError:
        logger.debug("File already deleted: %s", filepath)

Practical error handling checklist

When adding error handling to a function, ask these questions:

  • What specific exceptions can this code raise? Catch only those.
  • Can I recover from this error, or should I let it propagate?
  • Does the caller need a domain-specific exception type to handle this case?
  • Is there cleanup that must happen regardless of success or failure?
  • Am I preserving the original exception for debugging?

Run through this checklist any time you add a try/except block, and error handling stays a deliberate design choice instead of an afterthought.

Rune AI

Rune AI

Key Insights

  • Catch specific exception types, never use bare except.
  • Create custom exceptions for domain-specific error conditions.
  • Use else for code that runs only when no exception occurred.
  • Use finally for cleanup that must happen regardless of success or failure.
  • Chain exceptions with raise ... from to preserve the original cause.
RunePowered by Rune AI

Frequently Asked Questions

When should I catch an exception versus let it propagate?

Catch an exception only when you can do something useful about it, such as retrying, falling back to a default, logging, or translating it into a more meaningful error for the caller. If you cannot handle it, let it propagate so the caller can decide what to do.

Is it OK to use a bare except clause?

No. A bare except catches everything, including KeyboardInterrupt and SystemExit, which can prevent a program from shutting down. Always catch a specific exception type, or at minimum Exception to avoid catching system-level signals.

Conclusion

Exception handling is not about preventing crashes. It is about making failure states explicit, recoverable where possible, and debuggable where not. Catch specifically, clean up with finally, and never silence an error without a good reason.