Errors, Exceptions, & Warnings

At this stage in the lecture, you are already aware that error messages are a substantial part of a programmer’s life (no matter their skills). You have already learned ways to deal with difficult debugging situations (in the Debugging section here), and you have already raised an exception yourself. In this section you will find all the information on this topic that is relevant to you at one spot so that you can be confident in handling exceptions in your own code.

Copyright notice: parts of this section are taken from Fabien Maussion’s lecture material

Errors versus exceptions

While an error is a more general term referring to any kind of deviation from expected behavior, an exception specifically relates to run-time errors that are raised to handle exceptional conditions. Exceptions occur during the execution of a program and can be caused by various factors, such as division by zero, trying to access variables that are not defined, etc.

Exceptions

Here are a few examples of exceptions you might (have) run into.

10/0
ZeroDivisionError: division by zero
print(result)
NameError: name 'result' is not defined
int("ten")
ValueError: invalid literal for int() with base 10: 'ten'
2+'ten'
TypeError: unsupported operand type(s) for +: 'int' and 'str'

The last line of the error message indicates what happened. Exceptions come in different types, and the type is printed as part of the message: the types in the examples are ZeroDivisionError, NameError, ValueError and TypeError.

The rest of the line provides details based on the type of exception and what caused it.

The preceding part of the error message shows the context where the exception happened, the traceback. Note that you will not see the traceback here on the homepage, so produce an exception in a python console and inspect it. The traceback is a diagnostic report that displays the sequence of function calls and their associated line numbers that led to an exception being raised. This is particularly useful when the exception occurred somewhere deep in nested function calls. However, it will not display lines read from the interactive interpreter.

Raising Exceptions

When writing programs you will sometimes need to raise exceptions yourself. This can be done with the raise statement, like we have seen in our function that filters lists (exercise #02-09):

def filter_list(values, min_value=None, max_value=None):
    """Filters a list of numbers to keep only the ones between one or two thresholds.

    You need to set at least one of min_value or max_value!
    """

    if min_value is None and max_value is None:
        raise ValueError('Need to set at least one of min_value or max_value!')
    
    # <code omitted>

Generally, you have to make two decision. Which exception to raise, and what error message to send to the user. The built-in exceptions page lists all exceptions at your disposal and their meanings. Scroll through the list of possible standard errors: you will see that many of them have meaningful, informative names stating what the error is about. You should aim to use some of them for your own purposes. The type of exception you might be tempted to use most often is ValueError or TypeError.

When to raise an exception?

A well intentioned programmer might want to be nice to the user and write the following checks in a function:

def an_addition(a, b):
    # Check input
    if type(a) == str or type(b) == str:
        raise TypeError('We can only add numbers, not strings!')
    if type(a) != type(b):
        raise TypeError('We can only add numbers of the same type!')
    # OK, go
    return a + b

While it is sometimes useful to check the input of a function, these kinds of checks are considered bad practice in python, if the error messages sent by python itself are already informative enough. See the following example

def simple_addition(a, b):
    return a + b

simple_addition('1', 2)
TypeError: can only concatenate str (not "int") to str

Here the message is informative enough: so why should we bother adding our own message on top of that? As a rule of thumb: raise your own exception when you think that the default error message is not informative enough, or when you want to prevent semantic errors.

In case you do want to check for the data type of a function argument, you can use type() as shown above, or as in

number = 1.5
if type(number) not in (int, float):
        raise TypeError('Number needs to be an integer or float!')

Handling exceptions

If you expect parts of a program to raise exceptions in some cases, it might be useful to handle them and avoid the program to stop or to send a cryptic error message. Remember exercise #02-02, which asks the user for numeric input to classify energy consumption. In case the user provides character input, the function int() will fail. To prevent this we used a try..except block to handle the mishap gracefully. Now, that we also know about loops, we could re-write the code block to allow the user up to three input attempts until the program actually fails:

count = 0
while True:
    count += 1
    try:
        x = int(input("Please enter a number: "))
        break
    except ValueError:
        if count > 3:
            raise RuntimeError("Three input attempts failed, aborting..")
        print("Oops! That was no valid number. Try again...")

Make sure you fully understand the logic of the above code block, and consider playing with it interactively in a notebook. If you need to recap try..except blocks, go back to the reading material from workshop 1.

Two new details: A try statement may have more than one except clause, to specify handlers for different exceptions:

try:
    a = b + c
except ValueError:
    print('Ha!')
except NameError:
    print('Ho?')

An except clause may name multiple exceptions as a parenthesized tuple, for example:

except (RuntimeError, TypeError, NameError):
    print('All these are ok, we pass...')

Warnings

Warning messages are typically issued in situations where it is useful to alert the user of some condition in a program, where that condition (normally) doesn’t warrant raising an exception and terminating the program. For example, numpy issues warnings when mathematical computations lead to non-finite results.

Warnings are useful because they do not terminate the program:

import numpy as np

a = np.arange(10)
a / a  # issues a warning
/tmp/ipykernel_32196/2968769965.py:4: RuntimeWarning: invalid value encountered in divide
  a / a  # issues a warning
array([nan,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.])

This is a nice feature because this invalid division at one location does not imply that the rest of the computations are useless. NaNs (‘Not A Number’ will be explained in a future lesson) are an indicator for missing data, and most scientific libraries can deal with them.

Depending on your use case, you might want to disable warnings or turn them into errors. The following material is just for your information. I want you to have heard of these concepts, but you do not need to know how to apply them actively. Consider this for your reference in case you will dive deeper on programming in the future.

Silencing warnings

It is possible to temporarily disable warnings with the following syntax:

import warnings

with warnings.catch_warnings():
    warnings.simplefilter("ignore")
    b = a / a
b
array([nan,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.,  1.])

Warnings as exceptions

In a similar way, you can turn warnings into exceptions:

import warnings

with warnings.catch_warnings():
    warnings.simplefilter("error")
    b = a / a
RuntimeWarning: invalid value encountered in divide

The with statement in the examples above is called a context manager. As the name indicates, it serves to temporarily change the context of the code block that follows. In this case, it defines a part of the code where the warnings are silenced or errored. We will get back to context managers later in the lecture.

You can also disable warnings for an entire script or interpreter session simply by filtering them without using the context manager:

warnings.simplefilter("ignore")

This is not recommended, as it might hide important and unexpected warnings.

Raise warnings yourself

You can also raise warnings yourself. This can be useful when you run long computations on big data sets and some situations occur that you want to notify the user about. Typically these situations require some deeper analysis, but if they brake and stop the code from executing, then the user might spend a lot of time running the code, getting an error, fixing the (mostly semi-important) issue, running the code again, etc.

import warnings

def my_function(value):
    if value < 0:
        warnings.warn("Negative value encountered", UserWarning)

my_function(-5)
/tmp/ipykernel_32196/4004987079.py:5: UserWarning: Negative value encountered
  warnings.warn("Negative value encountered", UserWarning)

Learning checklist

  • I know what exceptions are, and I know some examples of exceptions.
  • I can raise exceptions myself.
  • I know how to handle exceptions gracefully using try...except statements.
  • I know that python code can also raise warnings.