Appearance
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 theerr.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 thatint(s)
itself did not error; however,int(s)
returned 0, leading to the error during the computation10 / 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.