Raise the right exceptions
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 be passed an argument of the wrong type.
- You might be passed an argument of the correct type, but of a bad or unsupported value (such as
divisor=0
).
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:
- These checks aren’t free and will run every time your function is called. Slowing down every successful call just to fail slightly faster out of calls that would have failed anyway isn’t a great trade-off.
- Explicit type checking with
isinstance()
is error-prone and can easily wind up doing the wrong thing entirely in Python, since you care much less often about the exact type or type name of a thing, and much more often about what a thing can do. For example, you almost never care whether something is exactly alist
, you care about whether it’s iterable, indexable, etc.; you can manually check this if you know what you’re doing and know your away around some of the abstract-class hierarchies Python provides, but it’s rarely worth it. - Explicit runtime type-checking like this is just generally bad practice in dynamically-typed languages like Python. The preferred approach would be, as in the example above, to just let the division operation fail and raise the
TypeError
automatically for you.
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.