Skip to content

Error Handling

During the execution of a program, if an error occurs, it is possible to define a convention to return an error code. This way, it becomes clear whether an error occurred and the reason for it. Returning error codes is very common in system calls provided by operating systems. For example, the open() function returns a file descriptor (an integer) on success and returns -1 on error.

Using error codes to indicate whether an error occurred is quite inconvenient, as the normal return values of a function are mixed with error codes, requiring the caller to use a lot of code to check for errors:

python
def foo():
    r = some_function()
    if r == -1:
        return -1
    # do something
    return r

def bar():
    r = foo()
    if r == -1:
        print('Error')
    else:
        pass

When an error occurs, it must be reported up through each level until a function can handle the error (for instance, displaying an error message to the user).

Therefore, high-level languages usually have built-in error handling mechanisms such as try...except...finally..., and Python is no exception.

Using try...except

Let’s take a look at an example of the try mechanism:

python
try:
    print('try...')
    r = 10 / 0
    print('result:', r)
except ZeroDivisionError as e:
    print('except:', e)
finally:
    print('finally...')
print('END')

When we suspect that some code might fail, we can run it inside a try block. If an error occurs, the subsequent code will not be executed, and control will jump directly to the error handling code, i.e., the except block. After executing the except block, if a finally block exists, it will be executed, and then the execution will complete.

In the above code, the computation 10 / 0 will produce a division error:

try...
except: division by zero
finally...
END

From the output, we can see that when the error occurs, the subsequent statement print('result:', r) is not executed. The except block executes because it catches the ZeroDivisionError. Finally, the finally statement is executed, and then the program continues down the flow.

If we change the denominator from 0 to 2, the execution result is as follows:

try...
result: 5
finally...
END

Since no error occurred, the except block is not executed, but if there is a finally, it will always be executed (though the finally block is optional).

You might guess that there are many types of errors, and different types of errors should be handled by different except blocks. That’s correct; you can have multiple except blocks to capture different types of errors:

python
try:
    print('try...')
    r = 10 / int('a')
    print('result:', r)
except ValueError as e:
    print('ValueError:', e)
except ZeroDivisionError as e:
    print('ZeroDivisionError:', e)
finally:
    print('finally...')
print('END')

The int() function might throw a ValueError, so we use one except to catch ValueError and another to catch ZeroDivisionError.

Moreover, if no errors occur, you can add an else after the except block, which will automatically execute if there are no errors:

python
try:
    print('try...')
    r = 10 / int('2')
    print('result:', r)
except ValueError as e:
    print('ValueError:', e)
except ZeroDivisionError as e:
    print('ZeroDivisionError:', e)
else:
    print('no error!')
finally:
    print('finally...')
print('END')

In Python, errors are also classes, and all error types inherit from BaseException. Therefore, when using except, it is important to note that it captures not only the specified type of error but also all its subclasses. For example:

python
try:
    foo()
except ValueError as e:
    print('ValueError')
except UnicodeError as e:
    print('UnicodeError')

The second except will never catch a UnicodeError because UnicodeError is a subclass of ValueError; thus, it will be caught by the first except.

All errors in Python derive from the BaseException class, and you can find common error types and their inheritance relationships here.

Using try...except to capture errors has a significant advantage: it can span multiple levels of calls. For example, if the main() function calls bar(), and bar() calls foo(), resulting in an error in foo(), as long as main() captures it, it can be handled:

python
def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    try:
        bar('0')
    except Exception as e:
        print('Error:', e)
    finally:
        print('finally...')

In other words, there is no need to capture errors at every potential failure point; just capturing them at the appropriate level is sufficient. This significantly reduces the hassle of writing try...except...finally.

Call Stack

If an error is not caught, it will keep propagating upwards until it is caught by the Python interpreter, which will print an error message and then terminate the program. Let’s look at an example err.py:

python
def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    bar('0')

main()

Running this will produce the following output:

$ python3 err.py
Traceback (most recent call last):
  File "err.py", line 11, in <module>
    main()
  File "err.py", line 9, in main
    bar('0')
  File "err.py", line 6, in bar
    return foo(s) * 2
  File "err.py", line 3, in foo
    return 10 / int(s)
ZeroDivisionError: division by zero

Errors are not terrible; the real issue is not knowing where the error occurred. Analyzing the error message is key to locating the error. We can observe the entire call chain of functions where the error occurred:

  • The first line of the error message:

    Traceback (most recent call last):

    indicates that this is the traceback information of the error.

  • Lines 2-3:

    File "err.py", line 11, in <module>
      main()

    indicate that calling main() caused an error in line 11 of the err.py file, but the reason is at line 9:

    File "err.py", line 9, in main
      bar('0')

    It tells us that calling bar('0') caused the error in line 9, and the reason lies in line 6:

    File "err.py", line 6, in bar
      return foo(s) * 2

    This indicates that the statement return foo(s) * 2 caused the error, but that’s not the final reason; we continue looking downwards:

    File "err.py", line 3, in foo
      return 10 / int(s)

    The source of the error is return 10 / int(s), which is where the error originated. Below, it prints:

    ZeroDivisionError: integer division or modulo by zero

    Based on the error type ZeroDivisionError, we can determine that int(s) itself did not error; however, int(s) returned 0, leading to the error during the computation 10 / 0. Thus, we have identified the source of the error.

Hint

When an error occurs, it’s essential to analyze the error’s call stack information to locate the error.

Tip for asking questions: Always include the traceback when asking for help regarding exceptions.

Logging Errors

If an error is not caught, the Python interpreter will print the error stack trace, but the program will terminate. Since we can catch errors, we can print the error stack trace and analyze the cause of the error while allowing the program to continue executing.

Python's built-in logging module makes it easy to log error messages:

python
import logging

def foo(s):
    return 10 / int(s)

def bar(s):
    return foo(s) * 2

def main():
    try:
        bar('0')
    except Exception as e:
        logging.exception(e)

main()
print('END')

When an error occurs, the program will print the error message and then continue to execute normally:

$ python3 err_logging.py
ERROR:root:division by zero
Traceback (most recent call last):
  File "err_logging.py", line 13, in main
    bar('0')
  File "err_logging.py", line 9, in bar
    return foo(s) * 2
  File "err_logging.py", line 6, in foo
    return 10 / int(s)
ZeroDivisionError: division by zero
END

With configuration, logging can also record

errors to log files for easier troubleshooting later.

Raising Errors

Since errors are classes, capturing an error means catching an instance of that class. Thus, errors are not generated arbitrarily; they are intentionally created and raised. Many built-in functions in Python raise various types of errors, and we can also raise errors in our own functions.

To raise an error, we can define a class for the error if needed, choose the appropriate inheritance relationship, and then use the raise statement to throw an instance of the error:

python
class FooError(ValueError):
    pass

def foo(s):
    n = int(s)
    if n == 0:
        raise FooError('invalid value: %s' % s)
    return 10 / n

foo('0')

Executing this will trace back to our defined error:

$ python3 err_raise.py 
Traceback (most recent call last):
  File "err_throw.py", line 11, in <module>
    foo('0')
  File "err_throw.py", line 8, in foo
    raise FooError('invalid value: %s' % s)
__main__.FooError: invalid value: 0

You should only define your own error types when absolutely necessary. If you can choose from Python's built-in error types (like ValueError, TypeError), it's best to use those.

Lastly, let’s examine another method of error handling:

python
def foo(s):
    n = int(s)
    if n == 0:
        raise ValueError('invalid value: %s' % s)
    return 10 / n

def bar():
    try:
        foo('0')
    except ValueError as e:
        print('ValueError!')
        raise

bar()

In the bar() function, we capture the error, print ValueError!, and then raise the error again using raise. This might seem odd, but this method of error handling is quite common. The goal of catching an error is to log it for later tracking. Since the current function does not know how to handle the error, the most appropriate action is to propagate it upwards for the top-level caller to handle. It’s like an employee passing an issue up to their boss; if the boss can't handle it either, they pass it up until it reaches the CEO.

The raise statement without parameters re-raises the current error. Furthermore, in except, you can raise a different type of error:

python
try:
    10 / 0
except ZeroDivisionError:
    raise ValueError('input error!')

Any reasonable conversion logic is acceptable, but you should never convert an IOError into an unrelated ValueError.

Exercise

Run the code below, analyze the exception information, locate the source of the error, and fix it:

python
from functools import reduce

def str2num(s):
    return int(s)

def calc(exp):
    ss = exp.split('+')
    ns = map(str2num, ss)
    return reduce(lambda acc, x: acc + x, ns)

def main():
    r = calc('100 + 200 + 345')
    print('100 + 200 + 345 =', r)
    r = calc('99 + 88 + 7.6')
    print('99 + 88 + 7.6 =', r)

main()

Summary

Python's built-in try...except...finally makes error handling very convenient. When an error occurs, analyzing the error message and locating the code where the error occurred is crucial.

Programs can also actively raise errors, allowing callers to handle the corresponding issues. However, it’s essential to document what types of errors might be raised and the reasons they occur.

Error Handling has loaded