raise and Custom Exceptions

raise signals a failure your own code has detected. A custom exception class gives that failure a name callers can catch precisely.

raise

def set_age(age):
    if not isinstance(age, int):
        raise TypeError("age must be a whole number")
    if age < 0:
        raise ValueError("age cannot be negative")
    if age > 150:
        raise ValueError(f"age {age} is not plausible")
    return age


print(set_age(27))
# set_age(-5)        # ValueError: age cannot be negative

Raising stops the function immediately and hands the failure to the caller. It is how a function refuses to continue with input it cannot honour.

Choosing the type

SituationRaise
Wrong type of argumentTypeError
Right type, unacceptable valueValueError
A key or index is absentKeyError / IndexError
The object is in the wrong state for this callRuntimeError
A subclass has not implemented thisNotImplementedError
The operation is not supported at allTypeError

Write a message that helps

raise ValueError("invalid")                                  # useless
raise ValueError("invalid input")                            # barely better
raise ValueError(f"expected a positive integer, got {age!r}")  # useful

Include what was expected, what arrived, and use !r so that "5" and 5 look different in the message.

Re-raising

def process(path):
    try:
        with open(path) as handle:
            return handle.read()
    except PermissionError:
        print(f"audit: permission denied for {path}")
        raise                        # bare raise keeps the original traceback
try:
    int("abc")
except ValueError as error:
    raise ValueError("could not parse the configuration") from error

A bare raise re-raises the current exception unchanged. raise X from error replaces it while recording the cause. Both preserve information; raise X on its own inside a handler loses the connection and makes debugging harder.

Custom exceptions

class ValidationError(Exception):
    """Raised when submitted data fails validation."""


def register(username):
    if len(username) < 3:
        raise ValidationError("username must be at least 3 characters")
    return username


try:
    register("ab")
except ValidationError as error:
    print("rejected:", error)

A class with a docstring and nothing else is a perfectly good exception. Inherit from Exception, never from BaseException.

Why bother

# Without a custom type, the caller cannot tell these apart
def charge(amount):
    if amount <= 0:
        raise ValueError("amount must be positive")
    if amount > balance:
        raise ValueError("insufficient funds")     # same type, different meaning


# With custom types, the caller can respond differently to each
class PaymentError(Exception):
    """Base class for every payment failure."""


class InvalidAmount(PaymentError):
    """The amount is not a usable value."""


class InsufficientFunds(PaymentError):
    """The account does not hold enough to cover this."""


def charge(amount, balance):
    if amount <= 0:
        raise InvalidAmount(f"amount must be positive, got {amount}")
    if amount > balance:
        raise InsufficientFunds(f"need {amount}, have {balance}")
    return balance - amount


try:
    charge(500, 100)
except InsufficientFunds as error:
    print("top up required:", error)
except PaymentError as error:
    print("payment problem:", error)

Always define a base class for your module

Exception
 +-- PaymentError            <-- callers can catch everything from this module
      +-- InvalidAmount
      +-- InsufficientFunds
      +-- CardDeclined

One base class per library or module lets a caller write except PaymentError: and be sure of covering your whole surface, without resorting to except Exception.

Carrying extra data

class ValidationError(Exception):
    """Raised when a field fails validation."""

    def __init__(self, field, value, reason):
        self.field = field
        self.value = value
        self.reason = reason
        super().__init__(f"{field}={value!r}: {reason}")


try:
    raise ValidationError("age", "-5", "must be positive")
except ValidationError as error:
    print(error)              # age='-5': must be positive
    print(error.field)        # age
    print(error.value)        # -5
    print(error.reason)       # must be positive

Calling super().__init__(message) is what makes str(error) useful. Store the individual pieces as attributes so a handler can act on them rather than parsing the message text.

class RetryableError(Exception):
    def __init__(self, message, retry_after=30):
        super().__init__(message)
        self.retry_after = retry_after


try:
    raise RetryableError("service busy", retry_after=60)
except RetryableError as error:
    print(f"{error}; try again in {error.retry_after}s")

Collecting several failures

class ValidationError(Exception):
    pass


def validate(record):
    problems = []

    if not record.get("name"):
        problems.append("name is required")
    if not isinstance(record.get("age"), int):
        problems.append("age must be a whole number")
    elif record["age"] < 0:
        problems.append("age cannot be negative")
    if "@" not in record.get("email", ""):
        problems.append("email is not valid")

    if problems:
        raise ValidationError("; ".join(problems))

    return record


try:
    validate({"age": "x", "email": "nope"})
except ValidationError as error:
    for problem in str(error).split("; "):
        print("-", problem)

Reporting every problem at once is far more useful to a user than stopping at the first one. Note the elif: the range check only runs when the type check passed.

assert is not error handling

def average(values):
    assert values, "values must not be empty"      # a developer assumption
    return sum(values) / len(values)


def average_safe(values):
    if not values:
        raise ValueError("values must not be empty")   # a real check
    return sum(values) / len(values)
Assertions are removed entirely when Python runs with the -O flag. Use assert to state something you believe can never happen; use raise to validate anything that comes from outside your program.

A worked example

class ConfigError(Exception):
    """Base class for configuration problems."""


class MissingSetting(ConfigError):
    def __init__(self, key):
        self.key = key
        super().__init__(f"required setting {key!r} is missing")


class InvalidSetting(ConfigError):
    def __init__(self, key, value, expected):
        self.key = key
        self.value = value
        super().__init__(f"setting {key!r} is {value!r}, expected {expected}")


def load_port(config):
    if "port" not in config:
        raise MissingSetting("port")
    try:
        port = int(config["port"])
    except (TypeError, ValueError) as error:
        raise InvalidSetting("port", config["port"], "a whole number") from error
    if not 1 <= port <= 65535:
        raise InvalidSetting("port", port, "a number between 1 and 65535")
    return port


for config in [{}, {"port": "abc"}, {"port": 99999}, {"port": "8080"}]:
    try:
        print("port:", load_port(config))
    except ConfigError as error:
        print("config error:", error)

Common mistakes

  • Raising a bare Exception instead of a specific or custom type.
  • Inheriting a custom exception from BaseException.
  • Overriding __init__ and forgetting to call super().__init__, so the message is lost.
  • Writing raise ValueError without brackets - legal, but it gives no message.
  • Using assert to validate user input.
  • Raising inside a loop and abandoning the remaining work when collecting failures would be better.

Best practices

  • Give every module or package one base exception class of its own.
  • Name exception classes ending in Error.
  • Put the values that caused the failure in the message, and also on the object as attributes.
  • Use raise ... from error when translating between layers.
  • Validate with raise; assert only your own internal assumptions.

Practice

  1. Write a small exception hierarchy for a library system: a base class plus three specific errors.
  2. Write a custom exception carrying a field name and an invalid value as attributes.
  3. Validate a record and report every problem in one exception rather than the first.
  4. Show why raise ValueError("x") from error produces a better traceback than raise ValueError("x") inside a handler.
  5. Explain when assert is appropriate and give one case where using it would be a security problem.

Conclusion

Raise when your code detects something it cannot honour, choose the type that describes the failure, and write a message containing the offending value. Give your module its own exception base class, and callers will be able to handle your failures precisely instead of catching everything.

Written by Lorens Mishra

Default administrator account created by the installer.

Continue reading

All Python notes →
Python

The Exception Hierarchy

Every exception is a class in one inheritance tree. Catching a parent catches all its children, which is what makes handler order and handler width ma...

Read more

Discussion

0 comments
Sign in to join the discussion.

No comments yet. Be the first to say something.