Proper Exception Handling in Python

1. Overview

Python encourages the EAFP style: just do it, and handle errors if they occur. Thus exceptions appear everywhere in Python code. Misuse causes exceptions to be silently swallowed, unreadable tracebacks, and duplicate logs.

This article covers the core techniques: try/except/else/finally, catching the right exception type, defining a custom exception system, chaining exceptions with raise ... from, logging, ExceptionGroup, and how to write tests. After reading, you’ll have a clear set of rules to apply directly to your projects. The examples use Python 3.11 and above.

2. Structure of try/except/else/finally

A complete exception handler has four parts:

def parse_quantity(raw: str) -> int:
    try:
        quantity = int(raw)
    except ValueError:
        print(f"Invalid quantity: {raw!r}")
        return 0
    else:
        print("Parse successful")
        return quantity
    finally:
        print("Parse complete")


print(parse_quantity("3"))
print(parse_quantity("ba"))

Role of each part:

  • try: contains only code that might throw an exception, here int(raw).
  • except ValueError: runs when int() fails to parse the string.
  • else: runs when try does not throw an exception. Happy-path code goes here instead of inside try.
  • finally: always runs, even if there’s a return or exception. Use this to clean up resources.

Output:

Parse successful
Parse complete
3
Invalid quantity: 'ba'
Parse complete
0

Note that finally runs before the return value is printed. Python executes finally right before exiting the function.

2.1. Why use else

Compare these two approaches:

# Not good: try is too broad
try:
    quantity = int(raw)
    total = quantity * unit_price  # what if unit_price is the wrong type?
except ValueError:
    total = 0

If unit_price is a string, the multiplication won’t throw ValueError. Yet many other operations might throw ValueError from places you didn’t expect, and that error gets mistaken for “bad quantity”. Keep try as narrow as possible. Put the rest in else.

3. Catch specific exceptions, don’t catch all

The most common mistake is using bare except: or except Exception: and ignoring it:

import time

def fetch_order_status(order_id: str) -> str:
    try:
        time.sleep(10)  # simulate slow API call
        return "PAID"
    except:  # very bad
        return "UNKNOWN"

Bare except: catches even KeyboardInterrupt and SystemExit. When you press Ctrl+C, the program doesn’t stop but returns "UNKNOWN". Python’s exception hierarchy is structured as follows:

BaseException
 ├── SystemExit
 ├── KeyboardInterrupt
 ├── GeneratorExit
 └── Exception
      ├── ValueError
      ├── KeyError
      ├── OSError
      └── ...

Rules:

  • Catch the narrowest type you can actually handle, for example KeyError, TimeoutError.
  • Use except Exception only at the “edge” of your application: worker loops, top-level HTTP handlers. When you do, always log.
  • Never use except BaseException or bare except: unless you re-raise immediately.

You can catch multiple types in one branch using a tuple:

def read_price(prices: dict[str, str], sku: str) -> float:
    try:
        return float(prices[sku])
    except (KeyError, ValueError) as exc:
        print(f"Could not read price for {sku}: {type(exc).__name__}")
        return 0.0


print(read_price({"A1": "12.5", "B2": "abc"}, "B2"))
print(read_price({"A1": "12.5"}, "Z9"))
Could not read price for B2: ValueError
0.0
Could not read price for Z9: KeyError
0.0

4. Define custom exceptions for your domain

For your business logic, you should create a base exception for the module, then inherit from it:

class PaymentError(Exception):
    """Base error for the payment module."""


class InsufficientFundsError(PaymentError):
    def __init__(self, order_id: str, balance: float, amount: float) -> None:
        super().__init__(
            f"Order {order_id}: balance {balance} is not enough to pay {amount}"
        )
        self.order_id = order_id
        self.balance = balance
        self.amount = amount


class CardDeclinedError(PaymentError):
    pass


class PaymentService:
    def __init__(self, balance: float) -> None:
        self.balance = balance

    def charge(self, order_id: str, amount: float) -> None:
        if amount > self.balance:
            raise InsufficientFundsError(order_id, self.balance, amount)
        self.balance -= amount

Usage:

service = PaymentService(balance=100)

try:
    service.charge("ORD-42", 250)
except InsufficientFundsError as exc:
    print(exc)
    print("Shortage:", exc.amount - exc.balance)
except PaymentError:
    print("Other payment error")
Order ORD-42: balance 100 is not enough to pay 250
Shortage: 150

This organization has three benefits:

  1. Calling code can catch PaymentError to handle all payment errors, or catch InsufficientFundsError separately if needed.
  2. Exceptions carry structured data (order_id, balance, amount) instead of just a string. The handler doesn’t need to parse the message.
  3. Your errors are separate from system errors like ValueError, so they won’t be caught by mistake.

Note: Inherit from Exception, not BaseException. Class names should end with Error, per PEP 8 convention.

5. Chain exceptions with raise ... from

When converting a low-level error to a domain error, you should preserve the original cause:

import json


class ConfigError(Exception):
    pass


def load_config(text: str) -> dict:
    try:
        return json.loads(text)
    except json.JSONDecodeError as exc:
        raise ConfigError("Config file is invalid") from exc


load_config("{port: 8080}")

The traceback will show both errors:

json.decoder.JSONDecodeError: Expecting property name enclosed in double quotes: line 1 column 2 (char 1)

The above exception was the direct cause of the following exception:

Traceback (most recent call last):
  ...
ConfigError: Config file is invalid

from exc assigns the original error to the __cause__ attribute. The debugger immediately sees which column the JSON error is on.

If you omit from exc, Python still chains via __context__, but the message will be “During handling of the above exception, another exception occurred”. This implies your handler failed, when it actually didn’t. Always write from explicitly.

When the original error is just an unhelpful internal detail, use from None to hide it:

def get_customer_tier(tiers: dict[str, str], customer_id: str) -> str:
    try:
        return tiers[customer_id]
    except KeyError:
        raise LookupError(f"Customer {customer_id} not found") from None

6. Clean up resources with context managers

For files, connections, and locks, with is safer than hand-written try/finally:

from pathlib import Path

def count_orders(path: Path) -> int:
    with path.open(encoding="utf-8") as f:
        return sum(1 for line in f if line.strip())

The file is always closed, even if the loop throws an exception.

When you really want to ignore an error, contextlib.suppress expresses intent better than try/except: pass:

from contextlib import suppress
from pathlib import Path

with suppress(FileNotFoundError):
    Path("cache/orders.tmp").unlink()

Reading this line, others immediately understand: “delete the file if it exists, otherwise move on”.

7. Logging and re-raising exceptions

In an except block, use logger.exception() to log with a full traceback:

import logging

logging.basicConfig(level=logging.INFO)
logger = logging.getLogger("orders")


def process_order(service: PaymentService, order_id: str, amount: float) -> bool:
    try:
        service.charge(order_id, amount)
    except InsufficientFundsError:
        logger.warning("Order %s was rejected due to insufficient funds", order_id)
        return False
    except PaymentError:
        logger.exception("Undefined payment error for order %s", order_id)
        raise
    return True

Key points:

  • logger.exception(...) is equivalent to logger.error(..., exc_info=True). This should only be called inside except.
  • Bare raise re-raises the current exception and preserves the traceback. Don’t write raise exc, because the traceback will get an extra line pointing to this spot.
  • Expected, anticipated errors (InsufficientFundsError) only need warning, not the full traceback.

Note: Avoid the “log then re-raise” pattern at every layer. A single error will appear five times in the log and create noise. Only log where you actually handle the error, or at the outermost edge of the application.

8. Handle multiple errors at once with ExceptionGroup

Starting with Python 3.11, you can raise multiple exceptions at once. This is useful when validating data or running multiple tasks in parallel:

def validate_order(order: dict) -> None:
    errors: list[Exception] = []
    if order.get("quantity", 0) <= 0:
        errors.append(ValueError("quantity must be greater than 0"))
    if not order.get("customer"):
        errors.append(KeyError("customer"))
    if errors:
        raise ExceptionGroup("Invalid order", errors)


try:
    validate_order({"quantity": 0})
except* ValueError as group:
    for e in group.exceptions:
        print("Invalid value:", e)
except* KeyError as group:
    for e in group.exceptions:
        print("Missing field:", e)
Invalid value: quantity must be greater than 0
Missing field: 'customer'

Unlike regular except, multiple except* branches can run together. Each branch receives an ExceptionGroup subgroup containing only errors of that type. If any errors aren’t handled by any branch, they’re automatically re-raised.

asyncio.TaskGroup uses this mechanism too. When multiple child tasks fail, you get an ExceptionGroup.

9. Test exception behavior with pytest

Install pytest:

pip install pytest

Write tests for PaymentService and load_config:

# test_payment.py
import pytest

from payment import (
    ConfigError,
    InsufficientFundsError,
    PaymentService,
    load_config,
)


def test_charge_raises_when_balance_is_not_enough():
    service = PaymentService(balance=100)

    with pytest.raises(InsufficientFundsError, match="ORD-42") as exc_info:
        service.charge("ORD-42", 250)

    assert exc_info.value.amount == 250
    assert service.balance == 100  # balance should not be deducted


def test_load_config_keeps_original_cause():
    with pytest.raises(ConfigError) as exc_info:
        load_config("{port: 8080}")

    assert exc_info.value.__cause__ is not None
    assert type(exc_info.value.__cause__).__name__ == "JSONDecodeError"

Explanation:

  • pytest.raises(...) fails the test if there’s no exception, or the wrong exception type.
  • match= takes a regex and matches against str(exception).
  • exc_info.value is the actual exception object. This lets you inspect attributes like amount and __cause__.
  • The first test also checks the state after the error: the balance should remain unchanged. This is often forgotten.

Run the tests:

pytest -q
..                                                    [100%]
2 passed in 0.02s

10. Common mistakes

Silently swallowing exceptions.

try:
    service.charge(order_id, amount)
except Exception:
    pass  # order "succeeded" even though no money was charged

This type of error is nearly impossible to debug. If you really need to ignore it, use suppress with a specific exception type and add a comment explaining why.

return in finally.

def risky() -> str:
    try:
        raise RuntimeError("data loss")
    finally:
        return "ok"  # exception disappears!

return, break, or continue in finally will suppress an exception in flight. Starting with Python 3.14, the interpreter raises a SyntaxWarning for this. Never write code like this.

Raising generic Exception.

raise Exception("error") forces callers to catch Exception, meaning they catch everything else too. Use an appropriate built-in exception (ValueError, TypeError) or a domain exception.

Using exceptions for normal flow in hot loops.

EAFP is Pythonic style. But if the “error” case happens most of the time, dict.get() or an if check is both faster and clearer:

# Better when the key is often absent
discount = discounts.get(customer_id, 0)

Using assert to validate input.

assert is disabled when running python -O. Use raise ValueError(...) for data from outside.

11. Conclusion

Proper exception handling in Python revolves around a few simple principles:

  • Keep try narrow, put subsequent code in else, and clean up with finally or with.
  • Catch the most specific exception type. Only catch Exception at application boundaries, and always log.
  • Create a custom exception system for your domain, carrying structured data.
  • Use raise ... from exc to preserve the original cause, and bare raise to re-raise.
  • Use ExceptionGroup and except* when multiple errors occur together.
  • Write tests with pytest.raises to check both the error type and the state after the error.

Following these rules, your tracebacks will be more readable, your logs will be cleaner, and errors won’t be silently swallowed anymore.

pythonexceptionerror-handlingpytestbest-practices