Generally, there are two kinds of errors that are "show-stoppers", in the sense that they will crash your program. These are
Syntax errors must be fixed before the program can run. However, run-time errors result in exceptions. Exceptions are called such because they arise in exceptional conditions. Here, we use exceptional in the literal sense—they are outside of ordinary operation, rather than exceptional as "really good". When an exception is raised, flow of control is interrupted and the exception needs to be handled.
We have seen a number of different kinds of exceptions.
NameError.
IndexError.
KeyError.
assert statement and it fails, we get an AssertionError.
These errors give us a clue into why our program might have failed. But at this point, you might notice something interesting about them: they are named in the same way that classes are. Yes, exceptions are a class in Python and there is an associated object with each of these exceptions. Everything truly is an object in the world of Python.
At first, exceptions seem insurmountable, but that's only because we don't know how to deal with them. But the fact that exceptions are objects tells us that there are ways that we are intended to work with them. In some sense, exceptions are actually a subset of errors that are recoverable. That is, even if an exception is encountered, there is a way for us to continue with the computation, because we're given this exception object. (Errors that crash your program without any exceptions are much more difficult to deal with.)
First, what does an exception do? When we run some code like
d = {}
d["a key that I think exists"]
print("This line will definitely run (not)")
our program encounters an error (a KeyError specifically) and ordinarily, our program stops and you either fix the problem or you go off to office hours for help.
>>> d = {}
>>> d["a key that I think exists"]
Traceback (most recent call last):
File "<stdin>", line 1, in <module>
KeyError: 'a key that I think exists'
But this doesn't have to be the case all the time. Up until now, you have generally encountered errors because your code was wrong. However, as you work with larger programs that interact with other programs and data, you will encounter errors that aren't necessarily your fault. This is good, because it's not your fault, but it's bad because you still have to deal with it.
But suppose that you could anticipate that there is some trouble code. What can you do about it? Well, we could try running it and seeing what happens. And if it works, then we're good. But if something does go wrong, we can try to handle the error somehow.
This is what a try statement does.
try:
<some code you think might give you a SomethingError>
except SomethingError:
<your code to deal with getting a SomethingError>
For example,
d = {}
try:
d["a key that I think exists"]
print("This line will definitely run (not)")
except KeyError:
print("You tried to access a key that doesn't exist in the dictionary")
print("This program continues running as normal.")
>>> try:
... d['a key that I think exists']
... print('this line will definitely run (not)')
... except KeyError:
... print("You tried to access a key that doesn't exist in the dictionary")
...
You tried to access a key that doesn't exist in the dictionary
There are a few things to note here. First of all, exceptions interrupt the flow of the program. Because exceptions signify exceptional events, when an exception is raised in the try block, control immediately stops and goes to the corresponding except block to handle the exception. Following that, the program continues after the try statement.
The fact that an exception is really an object is useful. This means that the exception object can contain information about the exception. We can use this information in the same way we would use any other object.
>>> d = {}
>>> try:
... d["a key that I think exists"]
... except KeyError as err:
... print("KeyError:", err)
...
KeyError: 'a key that I think exists'
>>> try:
... int("a number")
... except ValueError as err:
... print("Something went wrong:", err)
...
Something went wrong: invalid literal for int() with base 10: 'a number'
Each exception has its own information it provides.
Now, notice we explicitly specify which error we are going to try to handle. That way, if some other exception occurs, we don't erroneously handle it incorrectly. What if we do expect more than one kind of error?
There are two options. First, we can handle each of them, by adding an except block for each one under the same try. This is a common thing to do when, say, working with files—many things can go wrong, some of which may not be your fault.
>>> try:
... f = open("file.txt")
... s = f.readline()
... i = int(s.strip())
... except OSError as e:
... print("OSError:", e)
... except ValueError:
... print("Could not convert to integer")
... except Exception as err:
... print(f"Unexpected {err=}, {type(err)=}")
...
OSError: [Errno 2] No such file or directory: 'file.txt'
First, note that when an exception is raised inside of a try block, the rest of the statements in the block is not executed—control flow never returns inside the try once it leaves.
Notice that this also means that a try with several except blocks will only ever enter at most one except block.
Next, we see that there is an except block that catches Exception, which is a general exception type for all exceptions. In this case, the Exception block gets executed only if an exception is raised that is not one of the previous two. However, you generally do not want to rely on using only Exception, since you should try to deal with the exceptions you expect in appropriate ways.
If you would like to handle many different types of errors in the same way, you can combine them into one block.
>>> try:
... s = s + 23 + "forty"
... except (TypeError, ValueError, NameError) as e:
... print("Error:", type(e), e)
...
Error: <class 'NameError'> name 's' is not defined
Notice that while this statement can catch multiple exceptions, only one can ever get raised. The one that does end up getting raised is determined by the evaluation order of the expression.
Finally, in addition to try and except, we have the finally block. finally is an optional block that is run no matter what occurs in the previous blocks.
Why is finally necessary? Recall that exceptions modify the flow of control—any code in the try block that occurs after an exception is not run. This can be problematic, since there might be code that needs to be run regardless of the state of the program. For example, we may have an open file object that we need to close.
>>> try:
... f = open("missing-file.txt")
... for row in f:
... print(row)
... except OSError as e:
... print(e)
... finally:
... print("Finished attempting to read the file.")
...
[Errno 2] No such file or directory: 'missing-file.txt'
Finished attempting to read the file.
Again, it's worth following the flow of control carefully here.
FileNotFoundError is raised.
try block and enters the except block, where the exception is handled and the [Errno 2] message is printed.
except block is finished, the finally block is executed and after that is finished, control moves on to the next statement.
In the case that no exception is raised, the try block will successfully complete and control moves on to the finally block. Again, finally will always be executed.
One thing that having these mechanisms may tempt you to do is something like the following:
try:
x = 765/0
except ZeroDivisionError:
pass
print("Mission accomplished👍")
This is what we call failing silently. You can think of it as the programming equivalent of sweeping something under the rug. Sure, it might "solve" the problem right now, but eventually, it will come back to haunt you. For example, in this case, since nothing amiss has happened, we may assume that an assignment was properly made to x. What happens when our program tries to access x? It fails, but at a point much further away from the source of the problem. Not only does this cause a surprise (always a bad idea), but it makes it more difficult to debug. You will absolutely be penalized for handling exceptions in this way.
Something else you might be tempted to do is to just let your code encounter exceptions and deal with the consequences afterwards. This is not recommended. As we've seen, exceptions are exceptional conditions and because they are exceptional, handling them requires breaking out of the expected flow of control. It's this jumping around unexpectedly that makes understanding programs more difficult. If you are able to do avoid exceptions by doing some due diligence (like incorporating some simple checks beforehand), you should do so.