Exceptions are raised in response to errors that are encountered during program execution.

Some common exceptions in Python includes, SyntaxError, NameError,  TypeError, ValueError, ImportError, and many others.

In the following snippet we deliberately use a undeclared variable which leads to a NameError exception being raised. 

a = 10

#b is not defined
print(a + b)

When an exception is raised, the program comes to an immediate halt/crash unless  the exception  had been anticipated and handled accordingly. Python offers a structured way of handling exceptions through the try/except statement. The statement provides a way to catch the exceptions as they are raised giving us an opportunity to perform a corrective action rather than the application/program abruptly crashing.

Consider the following example:

a = 10

try:
    print(a + b)
except NameError:
    print("The program didn't crash.")

In this article we will explore on how the try/except statement work and why it is an important feature in Python language.

A deeper look on Exceptions.

Before we understand how to catch exceptions using the try/except blocks, it is important  to first have a clear understanding on what exceptions are.

Ultimately, all exceptions in Python inherit from the BaseException class, as demonstrated in the following snippet.

print(issubclass(NameError, BaseException))
print(issubclass(SyntaxError, BaseException))
print(issubclass(TypeError, BaseException))
print(issubclass(ValueError, BaseException))

It is worth noting that, almost all builtin exceptions that you will most likely encounter also inherits from the builtin Exception class, this will prove useful especially when catching builtin exceptions.

print(issubclass(NameError, Exception))
print(issubclass(SyntaxError, Exception))
print(issubclass(TypeError, Exception))
print(issubclass(ValueError, Exception))

You should however note that some user-defined exceptions may inherit directly from the BaseException class rather   than from the higher-level Exception  class.

Catching exceptions with try/except

The basic structure of the try/except statement is as shown below:

try:
    #contains the statements in which the anticipated exception may be raised
except:
    #catches the Exception if raised.
else:
    #executed only if the exception is not raised.
finally:
    #always executed whether the exception is raised or not.
a = 10
try:
    print(a + b)
except NameError:
    print("NameError exception has been raised.")
else:
    print("This will not be executed.")
finally:
    print("This will always be executed.")

The basic components of the the try/except statement is as follows:

  • try block: In this block we place the statements containing the code in which the anticipated exception may be raised. This block is necessary and any try/except statement must have one.
  • catch block: In this block we catch the exceptions if it is raised in the try block and then perform a corrective action if necessary. Python offers ways to access information about the raised exception, including its class, the message and other details.
  • else statement: This is an optional block whose statements only get executed if the exception is not raised.
  • finally block: This is also an optional block whose statements are always executed regardless of whether the exception is raised or not

Please note that for a try/except statement to be valid, at least two blocks should be present; the try block must be there alongside either the except block or a finally block, otherwise a  SyntaxError will be raised.

The try block is fairly simple to understand since it does not contain any additional parameters,  it is simply the try keyword followed by a colon and then the indented statements forming the blocks body.

try:
    a = 10
    b = 0
    print(a / b)
except ZeroDivisionError:
    print("Cant' divide by zero.")

let us take a deeper look into the remaining three blocks.

The except block 

The except block is probably the most versatile of the four blocks in a try/except statement. The most basic usage as we have already seen is as shown below:

try:
   ...
except <exception>:
    #do somethiing

However, the block can also be used with the as clause allowing us to take more context and details about the raised exceptions. The syntax is as  follows.

try: 
    #statement
except <exception> as <name>:
    #do something

For example, we can use the as clause to get the exact details of the raised exception.

try:
    print(10/0)
except ZeroDivisionError as e:
    print(e)
try:
    print(a)
except NameError as e:
    print(e)

As you can see above, the as clause allows us to interact directly with the exception object that has been raised.

Multiple except blocks

You can define as many except blocks as necessary, this is in contrast to the other three blocks where you can only have one of each.

Multiple except blocks allows us to conveniently handle exception from different exception classes. This way,  once an exception is encountered it gets mapped to the correct except block for handling.

try:
   ...
except <excpetion1> as e:
    #do something
except <excpetion2> as e:
    #do something
except <excpetion3> as e:
    #do something
except <excpetion4> as e:
    #do something
else:
    ...
finally:
    ...

The following example shows how to use multiple except blocks to catch exceptions from different classes.

try:
    print(int("Hello")) # raises a ValueError
except ValueError as e:
    print("ValueError was raised.")
    print(e)
except NameError as e:
    print("NameError was raised.")
    print(e)
except TypeError as e:
    print("TypeError was raised.")
    print(e)
except ZeroDivisionError as e:
    print("ZeroDivisionError was raised.")
    print(e)

catching exceptions through inheritance

We have already seen how we can catch specific exceptions, however sometimes we may want to handle all exceptions that belongs to a certain category of exceptions. The except block allows us to achieve this in that if we handle an exception, we have effectively handled all the exceptions that  are derived from that particular exception.

For example, IndexError and KeyError are both subclasses of  the LookupError class, this means that if we catch a LookupError exception, we will have effectively, handled both IndexError and KeyError. Consider the following example:

try:
    D = {"Canada": "Ottawa", "Japan": "Tokyo", "Kenya": "Nairobi"}
    x = D['India']#Raises a KeyError
except LookupError as e:
    print(e)
try:
    L = ['orange', 'mango', 'apple']
    x = L[10] #raises an IndexError
except LookupError as e:
    print(e)

Remember that we previously said that all exceptions in Python are ultimately derived from the BaseException class and that almost all builtin exceptions are derived from the Exception class. This means that we can literally handle any exception using either of this classes with the Exception class being a more popular option.

try:
    print(a)
except Exception as e:
     print(e)   
try:
    print(10 / 0)
except Exception as e:
    print(e)

Combining this with multiple except block allows us to handle all exceptions, while at the same time handling some exceptions individually. Consider the following example:

try:
    print(a) #raises a ValueError
except ValueError:
      print("A value error was encountered")
except Exception as e:
     print(e)

In the above example, if the exception raised is a ValueError, it will be handled by the first except block while if it belongs to another builtin exception class, it will be handled by the last except block.

Lastly, the except block can be used without any parameter, in which case, it will act as a catch-all block . Consider the following example.

try:
    print(a)
except:
    print("an error occurred!")

Note: Catch-all except blocks are a bad programming practice unless  there is a valid reason for doing so . This is because they allow exceptions to go unnoticed as any errors that occur outside of the expected exceptions will be silenced and the code will continue executing without any indication that something went wrong.

Exceptions should be handled specifically for each type of error that could be encountered, instead of having a single catch-all block, which is not specific enough to be useful.

The else block

As we have already said, the else block only gets executed if the try block executes successfully without any exception being raised. 

try:
    print('Hello, World!')
except KeyError:
     print("an error occurred")
else:
     print("No error was raised!. ")

You can only have one else block in a try/except statement.

The finally block

The statements in a finally block gets executed whether the exception was raised or not, this is useful in cases where we want to perform a final action before leaving the try/except statement.

You can have only one finally block and it should always come last after all the other blocks i.e  try, except(s) and else blocks.

try:
    print("Hello, world!")
except Exception as e:
    print(e)
finally:
    print("This always gets executed.")

This block can also be used together with the try block without the other blocks.

try:
    print("hello")
finally:
    print("Done!")

Conclusion

We have covered on how to use the try/except statement to conveniently handle exceptions that may occur during the program execution. 

Before wrapping up, one thing you should note about SyntaxErrors including IndentationError exceptions is that they are identified and  raised during the parsing phase meaning that at that time, the interpreter hasn't yet executed any logic and will therefore not get handled by the try/except statement.

try:
    print(
except SyntaxError:
     print("You can't handle Syntax Errors")

The exception to the above, is when we are executing the statements using functions such as exec() or eval(), since in such cases, the logic will have been effectively loaded.

my_string = "print(hello, world!"

try:
    exec(my_string)
except SyntaxError as e:
   print("The string has some errors.")