Python Exception Handling Best Practices: A Complete Guide

· 11 min read

Introduction

Every Python program encounters errors. Files do not exist. Network requests time out. Users enter unexpected input. APIs return unexpected data.

How you handle these situations determines whether your program crashes silently, crashes loudly with a useful message, or recovers gracefully and continues working.

Python’s exception handling system is powerful and flexible — but it is also easy to use incorrectly. Catching too broadly, swallowing errors silently, and ignoring the else and finally clauses are among the most common mistakes in Python code.

This guide covers:

  • How Python’s exception system works
  • The full try/except/else/finally structure
  • When to catch exceptions and when to let them propagate
  • How to write custom exceptions
  • Common anti-patterns and how to fix them
  • Real-world error handling strategies

All examples are tested on Python 3.12.


How Python Exceptions Work

When Python encounters an error, it raises an exception — an object that represents what went wrong. If the exception is not handled, it propagates up the call stack until it either reaches a handler or terminates the program with a traceback.

def divide(a, b):
    return a / b

def calculate():
    return divide(10, 0)

calculate()

Expected output:

Traceback (most recent call last):
  File "script.py", line 7, in <module>
    calculate()
  File "script.py", line 5, in calculate
    return divide(10, 0)
  File "script.py", line 2, in divide
    return a / b
ZeroDivisionError: division by zero

The traceback shows the full call stack — exactly which functions were called in which order. Reading tracebacks from the bottom up is the fastest way to find the source of an error.


The Exception Hierarchy

Python exceptions form a class hierarchy. All exceptions inherit from BaseException. Most user-facing exceptions inherit from Exception:

BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
    ├── ValueError
    ├── TypeError
    ├── KeyError
    ├── IndexError
    ├── FileNotFoundError
    ├── OSError
    ├── RuntimeError
    ├── AttributeError
    └── ... (many more)

This hierarchy matters because catching a parent class also catches all its children. Catching Exception catches almost everything. Catching OSError catches FileNotFoundError, PermissionError, and other file-related errors.

import sys
print(ZeroDivisionError.__mro__)

Expected output:

(<class 'ZeroDivisionError'>, <class 'ArithmeticError'>, <class 'Exception'>, <class 'BaseException'>, <class 'object'>)

The Full try/except/else/finally Structure

Python’s exception handling has four clauses, each with a distinct purpose:

try:
    # Code that might raise an exception
    result = risky_operation()

except ValueError as e:
    # Runs if ValueError is raised in the try block
    print(f"Invalid value: {e}")

except (TypeError, KeyError) as e:
    # Catches multiple exception types
    print(f"Type or key error: {e}")

else:
    # Runs only if NO exception was raised
    print(f"Success: {result}")

finally:
    # Always runs, whether or not an exception occurred
    cleanup()

Most developers know try and except. Fewer use else and finally correctly.

The else Clause

The else block runs only when the try block completes without raising any exception. This is subtle but important — it separates “code that might fail” from “code that runs on success”:

def read_config(path):
    try:
        f = open(path)
    except FileNotFoundError:
        print(f"Config file not found: {path}")
        return None
    else:
        # This only runs if open() succeeded
        data = f.read()
        f.close()
        return data

Without else, you might accidentally catch a FileNotFoundError raised inside the success path, not the open() call.

The finally Clause

The finally block always runs — even if an exception is raised, even if return is called inside try, even if the program is about to crash:

def process_file(path):
    f = None
    try:
        f = open(path)
        return f.read()
    except OSError as e:
        print(f"Error reading file: {e}")
        return None
    finally:
        if f:
            f.close()  # Always runs
            print("File closed")

finally is the right place for cleanup code: closing files, releasing locks, closing database connections, or logging that a function completed.


Catching Specific Exceptions

Always catch the most specific exception type you can. Broad catches hide bugs and make debugging harder.

Too Broad

# Bad — catches everything, including bugs you didn't intend to handle
try:
    data = json.loads(user_input)
    result = process(data)
    save(result)
except Exception:
    print("Something went wrong")

If process() has a bug that raises AttributeError, this code silently swallows it. You will never know the bug exists.

Appropriately Specific

import json

try:
    data = json.loads(user_input)
except json.JSONDecodeError as e:
    print(f"Invalid JSON input: {e}")
    return None

# process() and save() errors propagate normally — they are not JSON errors
result = process(data)
save(result)

Only the JSON parsing is inside the try block. Other errors propagate and surface as real errors.

Catching Multiple Types

When you genuinely need to handle multiple exception types the same way:

try:
    value = int(user_input)
except (ValueError, TypeError) as e:
    print(f"Could not convert to integer: {e}")

The as Clause — Using the Exception Object

The as clause gives you access to the exception object itself:

try:
    result = 10 / 0
except ZeroDivisionError as e:
    print(type(e))
    print(str(e))
    print(e.args)

Expected output:

<class 'ZeroDivisionError'>
division by zero
('division by zero',)

The exception object contains the error message and any additional context. For file errors, it includes the filename and OS error code:

try:
    open("nonexistent.txt")
except FileNotFoundError as e:
    print(e.filename)
    print(e.strerror)
    print(e.errno)

Expected output:

nonexistent.txt
No such file or directory
2

Raising Exceptions

You can raise exceptions explicitly using raise:

def set_age(age):
    if not isinstance(age, int):
        raise TypeError(f"Age must be an integer, got {type(age).__name__}")
    if age < 0 or age > 150:
        raise ValueError(f"Age must be between 0 and 150, got {age}")
    return age

Re-raising Exceptions

Inside an except block, bare raise re-raises the current exception without losing the original traceback:

def load_data(path):
    try:
        with open(path) as f:
            return json.load(f)
    except json.JSONDecodeError:
        print(f"Warning: {path} contains invalid JSON")
        raise  # Re-raises the same JSONDecodeError with original traceback

Exception Chaining

When one exception causes another, use raise ... from ... to preserve the chain:

def connect_to_database(url):
    try:
        return low_level_connect(url)
    except ConnectionRefusedError as e:
        raise RuntimeError(f"Could not connect to database at {url}") from e

The from e clause preserves the original ConnectionRefusedError as the __cause__ of the new RuntimeError. Both appear in the traceback, making debugging much easier.


Custom Exceptions

For any non-trivial project, define your own exception classes. This makes error handling code more expressive and allows callers to catch your specific errors without catching everything else.

Basic Custom Exception

class ValidationError(Exception):
    pass

class DatabaseError(Exception):
    pass

def validate_email(email):
    if "@" not in email:
        raise ValidationError(f"Invalid email address: {email}")

Custom Exception with Extra Data

class APIError(Exception):
    def __init__(self, message, status_code, endpoint):
        super().__init__(message)
        self.status_code = status_code
        self.endpoint = endpoint

def fetch_user(user_id):
    response = requests.get(f"/api/users/{user_id}")
    if response.status_code == 404:
        raise APIError(
            f"User {user_id} not found",
            status_code=404,
            endpoint=f"/api/users/{user_id}"
        )
    return response.json()

try:
    user = fetch_user(999)
except APIError as e:
    print(f"API error {e.status_code} at {e.endpoint}: {e}")

Exception Hierarchy for a Project

For larger projects, build a hierarchy of custom exceptions:

class AppError(Exception):
    """Base exception for this application."""
    pass

class ValidationError(AppError):
    """Raised when input validation fails."""
    pass

class DatabaseError(AppError):
    """Raised when a database operation fails."""
    pass

class NetworkError(AppError):
    """Raised when a network request fails."""
    pass

Callers can catch AppError to handle any application error, or catch ValidationError specifically to handle only validation failures.


Context Managers and with Statements

The with statement is the cleanest way to handle resources that need cleanup — files, network connections, locks, and database transactions. It guarantees cleanup even if an exception occurs:

# Without context manager — error-prone
f = open("data.txt")
try:
    data = f.read()
finally:
    f.close()

# With context manager — cleaner and safer
with open("data.txt") as f:
    data = f.read()
# f is automatically closed here, even if an exception occurred

Most Python objects that need cleanup implement the context manager protocol. Prefer with whenever it is available.


Common Anti-Patterns

Anti-Pattern 1: Catching Exception or BaseException Broadly

# Wrong
try:
    do_something()
except Exception:
    pass  # silently swallow everything

# Better
try:
    do_something()
except SpecificError as e:
    logger.error(f"Expected error occurred: {e}")

Catching BaseException is almost always wrong — it catches KeyboardInterrupt (Ctrl+C) and SystemExit, preventing the user from stopping your program.

Anti-Pattern 2: Empty except Blocks

# Wrong — you have no idea what went wrong
try:
    connect()
except:
    pass

# Better — at minimum, log the error
try:
    connect()
except Exception as e:
    logger.warning(f"Connection failed: {e}")

An empty except block is sometimes called “exception swallowing.” Errors disappear silently and become impossible to debug.

Anti-Pattern 3: Using Exceptions for Control Flow

# Wrong — using exceptions as a normal code path
def get_value(d, key):
    try:
        return d[key]
    except KeyError:
        return None

# Better — check first
def get_value(d, key):
    return d.get(key)  # returns None if key is missing

Exceptions have overhead and make code harder to follow. When Python provides a clean alternative — .get(), hasattr(), os.path.exists() — prefer it.

Anti-Pattern 4: Catching and Re-raising Without Adding Value

# Wrong — this adds nothing
try:
    result = compute()
except ValueError as e:
    raise ValueError(str(e))  # same exception, loses traceback

# Better — either handle it or let it propagate
result = compute()  # no try/except needed if you're not handling it

If you are not actually handling the exception — logging it, transforming it, or recovering from it — do not catch it. Let it propagate to a level that can handle it meaningfully.

Anti-Pattern 5: Too Much Code in try Blocks

# Wrong — which line caused the ValueError?
try:
    name = data["name"]
    age = int(data["age"])
    email = validate_email(data["email"])
    user = create_user(name, age, email)
    save_to_db(user)
except ValueError:
    print("Invalid data")

# Better — narrow the try block to the risky operation
name = data["name"]
try:
    age = int(data["age"])
except ValueError:
    print(f"Invalid age: {data['age']!r}")
    return None
email = validate_email(data["email"])

Narrow try blocks make it clear exactly what you are protecting against.


Real-World Patterns

Retry with Exponential Backoff

import time
import random

def retry(func, max_attempts=3, base_delay=1.0):
    for attempt in range(1, max_attempts + 1):
        try:
            return func()
        except Exception as e:
            if attempt == max_attempts:
                raise
            delay = base_delay * (2 ** (attempt - 1)) + random.uniform(0, 0.1)
            print(f"Attempt {attempt} failed: {e}. Retrying in {delay:.1f}s...")
            time.sleep(delay)

Logging Exceptions

import logging

logger = logging.getLogger(__name__)

def process_record(record):
    try:
        return transform(record)
    except ValueError as e:
        logger.warning("Skipping invalid record %s: %s", record.id, e)
        return None
    except Exception:
        logger.exception("Unexpected error processing record %s", record.id)
        raise

logger.exception() automatically includes the full traceback in the log output. Use it inside except blocks when you want to log and re-raise.

Graceful Degradation

def get_user_preferences(user_id):
    try:
        return fetch_from_cache(user_id)
    except CacheError:
        pass  # cache miss is acceptable

    try:
        prefs = fetch_from_database(user_id)
        cache.set(user_id, prefs)
        return prefs
    except DatabaseError as e:
        logger.error("Could not fetch preferences: %s", e)
        return DEFAULT_PREFERENCES  # fall back to defaults

Quick Reference

ClauseWhen It Runs
tryAlways — contains the code that might fail
exceptOnly when the specified exception is raised
elseOnly when NO exception was raised
finallyAlways — whether or not an exception occurred
PatternUse When
Specific exception typeYou know what can go wrong and how to handle it
Multiple exception typesTwo or more errors need the same handling
Custom exceptionYour code needs its own error vocabulary
raise ... from ...Wrapping a lower-level error in a higher-level one
Bare raiseRe-raising inside an except block
with statementManaging resources that need cleanup

Wrap-Up

Good exception handling is not about catching every possible error — it is about catching the right errors, at the right level, with the right response.

Three rules cover most situations:

  1. Catch specifically — name the exact exception type you expect, not Exception or bare except
  2. Handle or propagate — either do something useful with the exception, or let it propagate to a level that can
  3. Use finally for cleanup — or better yet, use with statements when available

For a deeper look at how Python’s execution model connects to exception propagation — including how the CPython VM handles the call stack when an exception is raised — see the deep dive on what happens when you run python script.py. For questions or future tutorial ideas, get in touch via the Contact page.