5.2 General Rules for try-except#

If there is a suitable handler#

When an exception is raised, if there is a try-except around the line of code that raised it, Python checks each of the except clauses, in order, to see if it handles the type of exception that was raised. The first except clause that does will handle the exception: execution will jump to its except clause, skipping over any additional lines that may exist in the try block. Then the program will continue with whatever comes after the whole try-except statement.

For example, consider what happens in this function if num2 is 0:

def divide(num1: int, num2: int) -> None:
    try:
        answer = num1 / num2
        print(f'The answer is {answer}')
    except ZeroDivisionError:
        print(f'Cannot divide {num1} by zero!')


if __name__ == '__main__':
    divide(23, 0)

The assignment statement answer = num1 / num2 will raise a ZeroDivisionError. The except clause matches it, so we immediately leave the try block and print Cannot divide 23 by zero!. The message saying “The answer is …” is skipped.

It is possible to end a try-except statement with a “bare” except clause, that is, one with no specific type of exception named.

    try:
        # Some code goes here.
        pass
    except ZeroDivisionError:
        print('Something went wrong: attempt to divide by zero!')
    except TypeError:
        print('Something went wrong: type error!')
    except:   # No type of exception specified
        print('Something went wrong: I have no idea what!')

Similarly, we can use Exception as a wildcard that catches (almost) every kind of exception:

    try:
        # Some code goes here.
        pass
    except ZeroDivisionError:
        print('Something went wrong: attempt to divide by zero!')
    except TypeError:
        print('Something went wrong: type error!')
    except Exception:  # Very broad type of exception specified
        print('Something went wrong: I have no idea what!')

In either case, PyCharm will warn us that this is a “Too broad exception clause”. You might wonder why, since it is similar to an if-statement that has a final else clause with no condition. While fine for if-statements, this is considered bad style for exceptions. It is good practice to be as specific as possible with the types of exceptions that we intend to handle. If there is a kind of exception that we didn’t specifically anticipate, or we don’t have specific code to handle, we should allow the exception to propagate on to other code that is better prepared to handle it.

How could there be more than one except clause for a given exception?#

Inheritance!

A clause that says except <X> will catch an exception of type X or any descendant of X. In the example below, function nonsense handles exceptions that are part of a little inheritance hierarchy:

class TopException(Exception):
    pass

class MiddleException(TopException):
    pass

class BottomException(MiddleException):
    pass

def nonsense(num: int) -> None:
    try:
        if num > 100:
            raise TopException
        elif num < 0:
            raise MiddleException
        elif num == 0:
            raise BottomException
        else:
            print('All is well.')
    except MiddleException:
        print('A MiddleException occurred!')

If we call nonsense(-3), a MiddleException will be raised and then caught, and we will see the message A MiddleException occurred!. But a BottomException is-a kind of MiddleException, so if we call nonsense(0), the BottomeException that is raised will be caught and handled just the same. What if we call nonsense(142)? This raises a TopException. Since TopException is not a kind of MiddleException, the exception is not caught; instead the stack frame is popped and the exception propogates to the caller.

Suppose we want to write the code so that it can specifically handle each of these types of exception. Because of the way that inheritance influences the matching of a raised exception to an except clause, we have to be careful about the order in which we place the except clauses. For example, here we put except TopException first:

def nonsense_v2(num: int) -> None:
    try:
        if num > 100:
            raise TopException
        elif num < 0:
            raise MiddleException
        elif num == 0:
            raise BottomException
        else:
            print('All is well.')
    except TopException:  # Catches all 3 types of exception!
        print('A TopException occurred!')
    except MiddleException:  # Cannot be reached.
        print('A MiddleException occurred!')
    except BottomException:  # Cannot be reached.
        print('A BottomException occurred!')

But except TopException catches all three of these types of exception, so neither of the subsequent except clauses can ever be reached. If we want all three except clauses to contribute, we must catch the exceptions in order from most-specific to least-specific:

def nonsense_v3(num: int) -> None:
    try:
        if num > 100:
            raise TopException
        elif num < 0:
            raise MiddleException
        elif num == 0:
            raise BottomException
        else:
            print('All is well.')
    except BottomException:
        print('A BottomException occurred!')
    except MiddleException:
        print('A MiddleException occurred! (and it was not a BottomException)')
    except TopException:
        print('A TopException occurred (and it was not a Bottom or MiddleException)')

If there is no suitable handler#

Suppose function <X> calls function <Y> and <Y> raises an exception. If there is no try-except around the line of code that raises the exception, or there is one but it lacks an except clause for the particular kind of exception raised, then the stack frame for <Y> immediately is popped. We come back to the line of code in <X> that called <Y>—and that line of code receives the exception. This process continues until either some function on the stack handles the exception, or the the whole stack has been popped empty. In that case, the user sees the exception.

Here’s an example where the call stack has several frames on it when an exception may occur:

def f3() -> None:
    x = input('Enter a number: ')
    print(100 / int(x))
    print('That went well')


def f2() -> None:
    f3()


def f1() -> None:
    f2()


if __name__ == '__main__':
    f1()
    print('All done.')

When the code reaches f3, the stack has on it frames for the main block (on the bottom), f1, f2, and f3 (on the top). If the user enters either 0 or something other than an integer, an exception will be raised in f3. Since there is no try-except clause, the function will immediately return, sending the exception to f2. At this point, it’s no different than if f2 had raised the exception itself. Since f2 has no try-except clause either, it immediately returns, sending the exception to f1, and f1 does the same, sending the exception to the main block. We have just that one frame left on the stack, and it is popped too, leaving the error message to land in the lap of the user:

Enter a number: no thanks
Traceback (most recent call last):
  File "148-materials/notes/exceptions/code/popall.py", line 16, in <module>
    f1()
  File "148-materials/notes/exceptions/code/popall.py", line 12, in f1
    f2()
  File "148-materials/notes/exceptions/code/popall.py", line 8, in f2
    f3()
  File "148-materials/notes/exceptions/code/popall.py", line 3, in f3
    print(100 / int(x))
ValueError: invalid literal for int() with base 10: 'no thanks'

Process finished with exit code 1

Below is a new version where we handle both kinds of exception. We chose to handle any ZeroDivisionError in f2 and any ValueError in f1, but we could have put these handlers anywhere along the chain of function calls.

def f3() -> None:
    x = input('Enter a number: ')
    print(100 / int(x))
    print('That went well')


def f2() -> None:
    try:
        f3()
    except ZeroDivisionError:
        print('In f2 and my call to f3 raised a ZeroDivisionError')


def f1() -> None:
    try:
        f2()
    except ValueError:
        print('In f1 and my call to f2 raised a ValueError')


if __name__ == '__main__':
    f1()
    print('All done.')

If the user enters 0, a ZeroDivisionError exception is raised and the frame for f3 is popped as before, but now in f2 there is a handler that takes care of the exception and the program can continue as if there had never been an exception raised.

Enter a number: 0
In f2 and my call to f3 raised a ZeroDivisionError
All done.

Or if the user enters something other than an integer, a ValueError is raised in f3, the frame for f3 is popped, as is the frame for f2, since it can’t handle that type of exception. But then we reach f1, which has a handler for ValueErrors. f1 catches the exception, and the program continues.

Enter a number: hee hee!
In f1 and my call to f2 raised a ValueError
All done.

If an except clause itself raises an exception#

Here, an except caluse itself raises an exception:

    try:
        # Some code goes here.
        pass
    except ZeroDivisionError:
        n = int('ridiculous!')   # Can't be handled by this try-except.
    except TypeError:
        print('Something went wrong: type error!')

The except clauses in this try-except statement only handle exceptions that occur in this try clause. So Python immediately stops, pops the stack, and passes the exception on to the code that called this method or function. That is, unless the try-except is nested inside another try-except. In CSC148, we won’t go further into this or some of the other special cases that can occur. See the Python documentation if you’d like to learn more.