Exceptions#

Midjourney: Exception in Time, ref. Salvador Dalí

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 ../_images/debug_vscode_2.png.

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.

Debugging in VS Code

In the debugging environment, you can then step through the code line by line by pressing ../_images/debug_vscode_2.png and thereby see how the program is executed and which variables change.

Quiz#

--- shuffleQuestions: true shuffleAnswers: true --- ### What is an exception in Python? - [x] An exception that interrupts the normal flow of the program - [ ] A special case in loops - [ ] A warning that can be ignored - [ ] A global variable ### Why are `try-except` blocks used? - [x] To respond to errors in a controlled manner - [x] To prevent the program from crashing - [ ] To initialize variables - [ ] To automatically repeat functions ### What happens when an exception occurs in the `try` block? - [x] The code in the `except` block is executed - [ ] The `try` block is ignored - [ ] The program ends automatically - [ ] The error is always ignored ### Sort the following lines to form a complete `try-except` block correctly: ```python # Annahme: fehlerhafte Division ``` 1. `try:` 2. ` ergebnis = 10 / 0` 3. ` print("Ergebnis:", ergebnis)` 4. `except:` 5. ` print("Fehler bei der Division")` ### Which error is demonstrated in this example? ```python x = 10 y = 0 z = x / y print("Ergebnis:", z) ``` - [x] Division by zero leads to a `ZeroDivisionError` exception - [ ] `print` may not be used inside try blocks - [ ] `x` must not be 10 - [ ] `y` must be declared as a string ### What is the advantage of using `Exception as e`? - [x] You can explicitly print the type and error message of the exception - [ ] This automatically solves the exception - [ ] The code becomes shorter - [ ] `as` is mandatory in every error handling ### Why should known exception types (like `TypeError`) be caught explicitly? - [x] To handle different errors specifically - [ ] Because Python wouldn't work otherwise - [ ] To make the output nicer - [ ] This is only necessary for `int` values ### Sort the following lines to correctly build a typed error handling for divisions: ```python # Example with division by 0 and wrong type ``` 1. `try:` 2. ` ergebnis = "zehn" / 0` 3. `except TypeError:` 4. ` print("Falscher Datentyp")` 5. `except ZeroDivisionError:` 6. ` print("Division durch 0")` ### When is the `else` block executed in `try-except-else`? - [x] Only when no exception occurs in the `try` block - [ ] If any error occurs - [ ] Always after the `except` - [ ] If `print()` is used in the `try` ### What does a `finally` block do in Python? - [x] The code in the `finally` is executed **always** – regardless of error or not - [ ] The code is skipped on errors - [ ] Only used with networking - [ ] Used to initialize variables ### When should you raise your own exceptions with `raise`? - [x] If you want to clearly mark errors and propagate them - [ ] If you want to break out of a loop - [ ] Only for syntax errors - [ ] Only for debugging purposes ### Why is it sensible to use specific error types like `ValueError`? - [x] So errors can be handled and differentiated more precisely - [ ] So `try` blocks become shorter - [ ] So the program runs faster - [ ] Because `Exception` is not allowed in Python ### Which error is demonstrated in this example? ```python if not isinstance(x, int): raise "Fehler" ``` - [x] `raise` expects an exception object, not a string - [ ] `raise` is not a valid Python keyword - [ ] Strings may not be compared with `if` - [ ] `int` is not a valid type ### Sort the following lines to define a safe division function with error handling: ```python # Example with division by 0 and type checking ``` 1. `def division(zaehler, nenner):` 2. ` if not isinstance(zaehler, (int, float)):` 3. ` raise ValueError("Zaehler ist ungültig")` 4. ` if nenner == 0:` 5. ` return None` 6. ` return zaehler / nenner` ### How do exceptions behave in a function call stack? - [x] They bubble up until they are handled or crash the program - [ ] They disappear after the first function call - [ ] They only cause an error in the current function - [ ] They are automatically ignored ### What does the following line do in a recursive function? ```python raise RecursionError("Recursion too deep") ``` - [x] It deliberately raises an error when recursion depth is too deep - [ ] It prevents an exception - [ ] It ends the recursion successfully - [ ] It converts the return value into an exception ### What is the stack? - [x] A data structure that stores function calls - [ ] A list of all variables in the program - [ ] A special data type for loops - [ ] A type of exception ### What is the heap? - [x] A memory area for dynamically created objects - [ ] A special kind of loop - [ ] A data type for numbers - [ ] A region for global variables ### What is the difference between Stack and Heap? - [x] Stack stores function calls, Heap stores dynamically created objects - [ ] Stack is faster, Heap slower - [ ] Stack is for loops, Heap for conditions - [ ] Stack is for global variables, Heap for local