Skip to content

Raise the right exceptions

Published on: December 11, 2023    Categories: Django, Python

This is part of a series of posts I’m doing as a sort of Python/Django Advent calendar, offering a small tip or piece of information each day from the first Sunday of Advent through Christmas Eve. See the first post for an introduction.

Let’s have an argument

Suppose you write a function like this:

def divide(dividend, divisor):
    """
    Divide ``dividend`` by ``divisor`` and return the result.

    """
    return dividend / divisor

There are a couple things that could go wrong here:

You might try to solve the first problem by adding type hints:

def divide(dividend: float, divisor: float) -> float:
    """
    Divide ``dividend`` by ``divisor`` and return the result.

    """
    return dividend / divisor

But this only helps if someone actually runs a type checker; Python itself remains a dynamically-typed language, and does not use type hints to enforce any sort of runtime behavior.

Don’t use the numeric hierarchy

The above may look a bit weird since we probably want to support passing values of not just type float but many numeric types — for example, dividing with an int probably ought to succeed, too, and so the obvious thing to turn to is the numbers module in the standard library, which provides a standard hierarchy of numeric types for various occasions. So, for example, you might want to annotate the arguments and return type of divide() as being numbers.Real.

Unfortunately, this doesn’t work, and static type checkers will disallow it. For reasons which are not particularly well-explained (beyond being described as a “shortcut”, though both the “shortcut” and the actual numeric types could have been supported) in the main type-hinting PEP, the numbers module is basically excluded from being useful with type checkers, and you’re directed to use int for true integer-only situations; float to mean float | int, and complex to mean complex | float | int.

So as strange as it looks, float is the correct annotation for the divide() function.

An exceptional case

So we can check the arguments and raise exceptions if they’re wrong. Which then raises the question of which exception(s) to raise, and that’s the real tip I want to get across today. The short answer is: TypeError for the case of a non-numeric argument, ValueError for the case of divisor=0.

The longer answer is that when you want to do some sort of validation of arguments passed to a Python function or method, TypeError is how you indicate a violation of your function or method’s signature: either you received too many arguments, or too few, or one or more arguments you received were of the wrong type. While ValueError is how you indicate that you received the correct number and type of arguments, but at least one of them was still of an unacceptable value.

So the divide() function could look like this:

def divide(dividend: float, divisor: float) -> float:
    """
    Divide ``dividend`` by ``divisor`` and return the result.

    """
    if not all(isinstance(arg, (float, int)) for arg in (dividend, divisor)):
        raise TypeError("All arguments to divide() must be float or int.")
    if divisor == 0:
        raise ValueError("Cannot divide by zero.")
    return dividend / divisor

The isinstance() check there enforces the same rules as static type checkers for the types of the arguments (see note above). Though you also could skip it; if someone passes in an argument or set of arguments that don’t support division, Python will automatically raise a TypeError anyway. For example, if you try divide(5, "hello") without the explicit type-check, you’ll get:

Traceback (most recent call last):
  File "divide.py", line 11, in <module>
    divide(5, "hello")
  File "divide.py", line 8, in divide
    return dividend / divisor
           ~~~~~~~~~^~~~~~~~~
TypeError: unsupported operand type(s) for /: 'int' and 'str'

And in general, unless you have a very compelling reason to type-check arguments at runtime, it’s best to just not do it and let things fail when they hit an incompatible operation. There are several reasons for this:

The only times I’d consider doing this are when the check is absolutely required — say, due to implementing a specification which mandates certain behavior — or when the operation to perform is potentially so expensive or long-running that failing fast is the better option (and in that case I’d still think long and hard about whether to do it).

Learn your exceptions

Beyond just the example above of TypeError versus ValueError, it’s worth being familiar with Python’s built-in exception classes and their intended uses. And if you use a library or framework, it’s good to learn your way around its exceptions, too; for example, understanding Django’s built-in exceptions — this is a partial list of them, and others are documented elsewhere, such as the Http404 exception in the views documentation — and when each one should be used is likely to help both understading and working with Django.