Exceptions#

Anything that can go wrong, will go wrong.
— Murphy’s Law #1
Slides/PDF#
Handling errors with exceptions#
Exceptions interrupt the normal flow of a program in the event of an error. They are used to communicate the error in the program (they usually include an error message) and are used to prevent the program from crashing uncontrollably in the event of an error.
Known exceptions should always be caught. In Python they are caught by the try-except
block. This starts with a try
and ends with an except
. After the try
follows the block with the known (or unknown) exception. After the except
follows the block for error handling.
It should be noted that with exceptions there is no value assignment (the value is not known). Let’s again consider the division by 0 known from branching Verzweigung bekannte Division durch 0 with its various error cases of zero division and incorrect input data types. Without precisely specifying these error cases we can write.
numerator = 10
denominator = 0
result = None
try:
# Block with a known exception
result = numerator / denominator
# This part is executed only if no exception occurred
print("Die Division war erfolgreich")
except:
# Error handling
print("Divisionsfehler")
print(f"Das Ergebnis von {numerator}/{denominator} = {result}")
Divisionsfehler
Das Ergebnis von 10/0 = None
The except
in the above case is untyped. This allows catching all errors that can occur (including unknown ones). Consequently, the try-except
block also catches errors of the wrong data type.
numerator = 'keine_zahl'
denominator = 2
result = None
try:
result = numerator / denominator
except:
print("Divisionsfehler")
print(f"Das Ergebnis von {numerator}/{denominator} = {result}")
Divisionsfehler
Das Ergebnis von keine_zahl/2 = None
The problem here is that we don’t get any information about the exception. Therefore it’s always sensible to catch the exact exception and display the error message. The most general type of error in Python is the Exception
, which we catch as a variable named e
as follows.
numerator = 'keine_zahl'
denominator = 0
result = None
try:
result = numerator / denominator
except Exception as e:
print(f"Divisionsfehler mit Fehler vom Typ `{type(e)}` und Meldung `{e}`")
print(f"Das Ergebnis von {numerator}/{denominator} = {result}")
Divisionsfehler mit Fehler vom Typ `<class 'TypeError'>` und Meldung `unsupported operand type(s) for /: 'str' and 'int'`
Das Ergebnis von keine_zahl/0 = None
If the exceptions are known, they should also be handled by type in order to display specific messages.
numerator = 'keine_zahl'
denominator = 0
result = None
try:
result = numerator / denominator
except TypeError as e:
print(f"Zaehler oder Nenner nicht vom Datentyp `int`")
except ZeroDivisionError as e:
print("Teilung durch 0")
print(f"Das Ergebnis von {numerator}/{denominator} = {result}")
Zaehler oder Nenner nicht vom Datentyp `int`
Das Ergebnis von keine_zahl/0 = None
The try-except
block also supports the else
clause, which is always executed when no exception occurs. In addition, there is the finally
clause, which always executes, i.e., in both the error-free case and in the case of an error.
For example, if you want to perform output only when the try
block has succeeded, use else
.
numerator = 2
denominator = 1
result = None
try:
result = numerator / denominator
except TypeError as e:
print(f"Zaehler oder Nenner nicht vom Datentyp `int`")
except ZeroDivisionError as e:
print("Teilung durch 0")
else:
print(f"Die Division war erfolgreich und resultiert in {numerator}/{denominator} = {result}")
Die Division war erfolgreich und resultiert in 2/1 = 2.0
If you want something to always be executed, you can use finally
. This is commonly used to perform steps that should also be done in the event of an error, e.g., to close connections to files or to a database, so that they do not remain open indefinitely (and eventually cause errors).
numerator = 'keine_zahl'
denominator = 0
result = None
try:
result = numerator / denominator
except TypeError as e:
print(f"Zaehler oder Nenner nicht vom Datentyp `int`")
except ZeroDivisionError as e:
print("Teilung durch 0")
finally:
print(f"Das Ergebnis von {numerator}/{denominator} = {result}")
Zaehler oder Nenner nicht vom Datentyp `int`
Das Ergebnis von keine_zahl/0 = None
Creating your own exceptions#
Exceptions can also be raised with raise
. This is useful when in your own code errors may occur that need to be handled elsewhere. Here, an error type must be specified. Either you use the most general error type Exception
, a suitable standard Python error, or a user-defined one. It is usually advisable to use more specific error types, since the type of the error helps a lot when handling and debugging.
Let’s define, as an example, our own division function with clear error handling for whether the numerator or denominator is of the wrong type. Here we want to raise a ValueError
instead of a TypeError
. For a division by zero, instead of raising an error, the value None
should be returned.
def division(numerator, denominator):
if not isinstance(denominator, (int, float)):
raise ValueError("Denominator not of type `int` or `float`")
elif not isinstance(numerator, (int, float)):
raise ValueError("Numerator not of type `int` or `float`")
elif denominator == 0:
print("Warning division by 0")
return None
else:
result = numerator / denominator
print(f"The result of {numerator}/{denominator} = {result}")
return result
numerator = 'keine_zahl'
denominator = 0
result = division(numerator, denominator)
---------------------------------------------------------------------------
ValueError Traceback (most recent call last)
Cell In[8], line 4
1 numerator = 'keine_zahl'
2 denominator = 0
----> 4 result = division(numerator, denominator)
Cell In[7], line 5, in division(numerator, denominator)
3 raise ValueError("Denominator not of type `int` or `float`")
4 elif not isinstance(numerator, (int, float)):
----> 5 raise ValueError("Numerator not of type `int` or `float`")
6 elif denominator == 0:
7 print("Warning division by 0")
ValueError: Numerator not of type `int` or `float`
numerator = 10
denominator = 2
numerator = 10
denominator = 1
result = division(numerator, denominator)
The result of 10/1 = 10.0
Error handling for functions on the stack#
Exceptions are propagated up the call stack (the order of function calls) until they are either caught and handled or the program crashes when the end of the call stack is reached. The goal of catching and handling exceptions is to bring the program back to a state where it can continue running.
As an example, we want to define a recursive function that prints an error when the recursion depth becomes too large in order to avoid a stack overflow error. We will use the factorial function from the Recursion example to illustrate this.
def factorial_recursive(x, depth=1):
if depth > 20:
raise RecursionError("Recursion zu tief")
if x > 1:
return x * factorial_recursive(x-1, depth+1)
else:
return 1
factorial_recursive(20)
2432902008176640000
factorial_recursive(21)
---------------------------------------------------------------------------
RecursionError Traceback (most recent call last)
Cell In[13], line 1
----> 1 factorial_recursive(21)
Cell In[11], line 5, in factorial_recursive(x, depth)
3 raise RecursionError("Recursion zu tief")
4 if x > 1:
----> 5 return x * factorial_recursive(x-1, depth+1)
6 else:
7 return 1
Cell In[11], line 5, in factorial_recursive(x, depth)
3 raise RecursionError("Recursion zu tief")
4 if x > 1:
----> 5 return x * factorial_recursive(x-1, depth+1)
6 else:
7 return 1
[... skipping similar frames: factorial_recursive at line 5 (17 times)]
Cell In[11], line 5, in factorial_recursive(x, depth)
3 raise RecursionError("Recursion zu tief")
4 if x > 1:
----> 5 return x * factorial_recursive(x-1, depth+1)
6 else:
7 return 1
Cell In[11], line 3, in factorial_recursive(x, depth)
1 def factorial_recursive(x, depth=1):
2 if depth > 20:
----> 3 raise RecursionError("Recursion zu tief")
4 if x > 1:
5 return x * factorial_recursive(x-1, depth+1)
RecursionError: Recursion zu tief
In the error message above, the program itself hangs (however, the entire kernel doesn’t crash, as with a stack overflow error). To avoid that, we need to catch the error with a try-except
. In the error message above, the stack trace is already starting to appear, i.e., the list of function calls that has been collected on the stack.
import traceback
try:
factorial_recursive(21)
except RecursionError as e:
print(e)
traceback.print_exc()
Recursion zu tief
Traceback (most recent call last):
File "/var/folders/0c/805v004947lf04d_w7xm50rc0000gn/T/ipykernel_18941/458010054.py", line 3, in <module>
factorial_recursive(21)
File "/var/folders/0c/805v004947lf04d_w7xm50rc0000gn/T/ipykernel_18941/3426368764.py", line 5, in factorial_recursive
return x * factorial_recursive(x-1, depth+1)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/0c/805v004947lf04d_w7xm50rc0000gn/T/ipykernel_18941/3426368764.py", line 5, in factorial_recursive
return x * factorial_recursive(x-1, depth+1)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
File "/var/folders/0c/805v004947lf04d_w7xm50rc0000gn/T/ipykernel_18941/3426368764.py", line 5, in factorial_recursive
return x * factorial_recursive(x-1, depth+1)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
[Previous line repeated 17 more times]
File "/var/folders/0c/805v004947lf04d_w7xm50rc0000gn/T/ipykernel_18941/3426368764.py", line 3, in factorial_recursive
raise RecursionError("Recursion zu tief")
RecursionError: Recursion zu tief
Debugging with print()
#
Logical errors often only occur dynamically and cannot be found by static code analysis via lint tools. Here you have to trace the current program flow. This is called debugging. The simplest form is the print
debugging, where you flood the code with print()
statements.
Let’s extend our division function with lots of print()
statements. It’s common, for example, to print the input parameters, to print errors and warnings, and then to log the results.
def division(numerator, denominator):
print(f"Debug: Input Numerator: {numerator}")
print(f"Debug: Input Denominator: {denominator}")
if not isinstance(denominator, (int, float)):
print(f"Error: Denominator not of type `int` or `float`, but of type {type(denominator)}")
raise ValueError(f"Denominator not of type `int` or `float`, but of type {type(denominator)}")
elif not isinstance(numerator, (int, float)):
print(f"Error: Numerator not of type `int` or `float`, but of type {type(numerator)}")
raise ValueError(f"Numerator not of type `int` or `float`, but of type {type(numerator)}")
elif not denominator:
print("Warning: Division by 0")
return None
else:
result = numerator / denominator
print(f"Info: The result of {numerator}/{denominator} = {result}")
return result
Now we can, in particular, clearly trace exactly what happened in the event of an error. For example, in the case of division by zero.
division(10, 0)
Debug: Input Numerator: 10
Debug: Input Denominator: 0
Warning: Division by 0
However, we also have a lot of output even in the correct case. That can be very distracting, because you can miss real errors very quickly. For example, we perform ten divisions, one of which was a division by zero.
for denominator in range(-2, 8):
division(10, denominator)
Debug: Input Numerator: 10
Debug: Input Denominator: -2
Info: The result of 10/-2 = -5.0
Debug: Input Numerator: 10
Debug: Input Denominator: -1
Info: The result of 10/-1 = -10.0
Debug: Input Numerator: 10
Debug: Input Denominator: 0
Warning: Division by 0
Debug: Input Numerator: 10
Debug: Input Denominator: 1
Info: The result of 10/1 = 10.0
Debug: Input Numerator: 10
Debug: Input Denominator: 2
Info: The result of 10/2 = 5.0
Debug: Input Numerator: 10
Debug: Input Denominator: 3
Info: The result of 10/3 = 3.3333333333333335
Debug: Input Numerator: 10
Debug: Input Denominator: 4
Info: The result of 10/4 = 2.5
Debug: Input Numerator: 10
Debug: Input Denominator: 5
Info: The result of 10/5 = 2.0
Debug: Input Numerator: 10
Debug: Input Denominator: 6
Info: The result of 10/6 = 1.6666666666666667
Debug: Input Numerator: 10
Debug: Input Denominator: 7
Info: The result of 10/7 = 1.4285714285714286
Debugging with logging
#
Therefore, in more complex programs, one usually uses a logging
package. They allow you to assign categories to print
statements and filter by those categories. In the Python library logging
, the categories are: debug
, info
, warning
, error
, and critical
.
import logging
log = logging.getLogger("meinlog")
def division(numerator, denominator):
log.debug(f"Eingabe Zaehler: {numerator}")
log.debug(f"Eingabe Nenner: {denominator}")
if not isinstance(denominator, (int, float)):
log.error(f"Nenner nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(denominator)}")
raise ValueError(f"Nenner nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(denominator)}")
elif not isinstance(numerator, (int, float)):
log.error(f"Zaehler nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(numerator)}")
raise ValueError(f"Zaehler nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(numerator)}")
elif not denominator:
log.warning("Division durch 0")
return None
else:
result = numerator / denominator
log.info(f"Das Ergebnis von {numerator}/{denominator} = {result}")
return result
If we call the function now, we only see the division-by-zero warning.
log.setLevel(logging.WARNING)
for denominator in range(-2, 8):
division(10, denominator)
Division durch 0
However, if needed, just like during debugging, we can also raise the log level. For example, we want to receive all debug messages.
log.setLevel(logging.DEBUG)
for denominator in range(-2, 8):
division(10, denominator)
Division durch 0
Furthermore, logging can automatically include additional information as well. We can already see in the log above that not only the level (INFO, DEBUG, WARNING) but also the logger name (meinlog) are included. We can customize this format to, for example, also output the timestamp, which is especially important for understanding when something happened.
sh = logging.StreamHandler()
formatter = logging.Formatter('%(asctime)s %(name)s %(levelname)s: %(message)s')
sh.setFormatter(formatter)
log.addHandler(sh)
log.setLevel(logging.INFO)
for denominator in range(-2, 8):
divide(10, denominator)
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[21], line 8
5 log.setLevel(logging.INFO)
7 for denominator in range(-2, 8):
----> 8 divide(10, denominator)
NameError: name 'divide' is not defined
In practice, logging is very commonly used, especially in cloud applications. Since they don’t have screens, errors have to be found in the logs. As long as everything is okay, the application runs at the log level INFO
, with only a small amount of output. If an error occurs, the server is set to the log level DEBUG
, and one then searches the detailed logs for information to narrow down the error.
Debugging via Debug Interfaces#
Many integrated development environments (IDEs) offer the ability to run the debugger directly. These debuggers allow you to interrupt the dynamic execution of the code. The goal is to pause execution just before the error occurs, so you can observe the error behavior in detail.
There are usually two forms of interruption supported:
Pausing at specific lines of code using breakpoints.
Pausing on specific exceptions.
Debugging in Jupyter-Notebooks in VSCode#
The debugging interfaces look somewhat different depending on the IDE, but they have similar features. This Jupyter Notebook was written in VS Code, which we will use as the first example.
In most IDEs, you can usually click to the left of a line to set a breakpoint ⬤. We’ll set a breakpoint on line 11 at the point where the warning is emitted.
The code is then executed in a special debugging environment that allows the execution to be interrupted. In our notebook in VSCode, it is started by the symbol .
def divide(numerator, denominator):
log.debug(f"Eingabe Zaehler: {numerator}")
log.debug(f"Eingabe Nenner: {denominator}")
if not isinstance(denominator, (int, float)):
log.error(f"Nenner nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(denominator)}")
raise ValueError(f"Nenner nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(denominator)}")
elif not isinstance(numerator, (int, float)):
log.error(f"Zaehler nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(numerator)}")
raise ValueError(f"Zaehler nicht vom Datentyp `int` oder `float`, sondern vom Typ {type(numerator)}")
elif not denominator:
log.warning("Division durch 0")
return None
else:
result = numerator / denominator
log.info(f"Das Ergebnis von {numerator}/{denominator} = {result}")
return result
divide(10, 0)
2025-08-17 14:13:16,891 meinlog WARNING: Division durch 0
This starts the debug mode. In this mode, the current line is highlighted, and the current variables in memory are displayed.
In the debugging environment, you can then step through the code line by line by pressing and thereby see how the program is executed and which variables change.