Raising & Custom Exceptions

Your own code should raise exceptions too — when it receives invalid input or reaches a state it can't handle. Clear exceptions, including custom exception classes for your application, make failures easy to understand and to handle correctly higher up.

raise

PY raise.py
def withdraw(balance, amount):
    if amount <= 0:
        raise ValueError("Amount must be positive")
    if amount > balance:
        raise ValueError(f"Insufficient funds: balance {balance}, requested {amount}")
    return balance - amount

print(withdraw(100, 30))

for amount in (-5, 500):
    try:
        withdraw(100, amount)
    except ValueError as err:
        print("Error:", err)
Output
70
Error: Amount must be positive
Error: Insufficient funds: balance 100, requested 500

Raise an exception instance with a helpful message: what went wrong, and ideally the values involved. Choose the built-in type that fits — ValueError for bad values, TypeError for wrong types, KeyError/LookupError for missing items.

Re-raising

Sometimes you want to react to an exception (log it, clean up) but still let it propagate. A bare raise inside except re-raises the current exception unchanged:

PY reraise.py
def load(path):
    try:
        raise FileNotFoundError(path)
    except FileNotFoundError:
        print(f"log: could not open {path}")
        raise

try:
    load("settings.toml")
except FileNotFoundError as err:
    print("caller handles:", err)
Output
log: could not open settings.toml
caller handles: settings.toml

Custom exception classes

Defining your own exception classes lets callers catch your errors specifically, and carry extra information:

PY custom.py
class AppError(Exception):
    """Base class for all errors raised by this application."""

class ValidationError(AppError):
    def __init__(self, field, message):
        super().__init__(f"{field}: {message}")
        self.field = field

class PaymentDeclined(AppError):
    def __init__(self, reason, retryable=False):
        super().__init__(f"payment declined ({reason})")
        self.retryable = retryable

def checkout(order):
    if not order.get("email"):
        raise ValidationError("email", "is required")
    if order["total"] > 1000:
        raise PaymentDeclined("limit exceeded", retryable=False)
    return "paid"

for order in [{"total": 10}, {"email": "a@x.com", "total": 5000}, {"email": "a@x.com", "total": 50}]:
    try:
        print(checkout(order))
    except ValidationError as err:
        print(f"Please fix the {err.field} field — {err}")
    except AppError as err:
        print(f"Something went wrong: {err}")
Output
Please fix the email field — email: is required
Something went wrong: payment declined (limit exceeded)
paid

A common structure is one base class for the application (AppError) with specific subclasses. Callers can catch a specific error or all of yours at once, while genuine bugs (like TypeError) still surface.

Chaining: raise … from …

When you convert a low-level exception into a higher-level one, chain them so the original cause isn't lost:

PY chaining.py
import json

class ConfigError(Exception):
    pass

def load_config(text):
    try:
        return json.loads(text)
    except json.JSONDecodeError as err:
        raise ConfigError("config file is not valid JSON") from err

try:
    load_config("{bad json")
except ConfigError as err:
    print(err)
    print("caused by:", type(err.__cause__).__name__)
Output
config file is not valid JSON
caused by: JSONDecodeError

In a full traceback, Python prints both exceptions with "The above exception was the direct cause of the following exception". Use from None to deliberately hide the original when it adds nothing.

Adding notes

PY notes.py
def process(rows):
    for i, row in enumerate(rows, 1):
        try:
            int(row)
        except ValueError as err:
            err.add_note(f"while processing row {i}")
            raise

try:
    process(["1", "2", "x"])
except ValueError as err:
    print(err)
    print(err.__notes__)
Output
invalid literal for int() with base 10: 'x'
['while processing row 3']

add_note() attaches extra context that appears at the end of the traceback.

assert: checking your own assumptions

PY assert.py
def average(values):
    assert len(values) > 0, "average() needs at least one value"
    return sum(values) / len(values)

print(average([2, 4]))
average([])
Output
3.0
Traceback (most recent call last):
  File "main.py", line 6, in <module>
    average([])
    ~~~~~~~^^^^
  File "main.py", line 2, in average
    assert len(values) > 0, "average() needs at least one value"
           ^^^^^^^^^^^^^^^
AssertionError: average() needs at least one value

assert is for catching programming mistakes during development, not for validating user input: assertions are removed entirely when Python runs with the -O optimisation flag. Raise a proper exception for anything that can happen in normal use.

Guidelines

  • Fail early and loudly: validate inputs at the start of a function.
  • Write messages for the person who'll read the traceback — include the bad value.
  • Don't use exceptions for ordinary control flow when a simple if is clearer.
  • Document what your functions raise in their docstrings.

Try it yourself

Try it yourself
Console output