Exception handling is a process of responding to exceptions that occur during the execution of a program. Without exception handling, programs would not be able to recover from errors.

In normal situations, whenever an exception is encountered, the program comes to an immediate stop. Exception handling allows us  to catch the exception, perform a corrective action if necessary, and then continue with the program's execution from the point the exception was raised. This prevents the program from abruptly halting.

Python offers three keywords for use in handling exceptions, these are, try, except and finally. The three keywords provides an organized, structured way to handle the exceptions and continue running the program.

Try and except blocks

The try and except blocks are used together to "catch" exceptions as they are raised.

What simply goes on is that, we put the statement(s) that can raise an exception in the try block and then we put  the corrective statements under the except block. If the statement(s) under the try block raises an exception, the exception object is passed to the except block as the statements under its block gets executed.

try:
    print(a, b, c)
    
except:
    print("There was an error.")

In the above example, we used undefined variables, a, b, and c. Under normal circumstances, a NameError would have been raised but by using the try except block, we effectively catch the error and respond accordingly.

Used as above, the except block acts as a catchall block. It responds to any type of an exception that will be raised except the SyntaxError which can't be handled except when executing code dynamically using the eval(). or the exec() functions.

try:
    #this should have raised an IndexError
    L = [1, 2, 3, 4]
    print(L[10])

except:
    print("There was an error.")
try:
    #this should have raised an ZeroDivisionError
    print(1 / 0)
except:
    print("There was an error.")

Did we really "catch" the exception?

In our previous example, we used the try-except blocks without really caring what exception was being raised. However the except block is capable of getting the specific exception that was raised.

Remember that,  ultimately, all exceptions are subclasses of the Exception class. When we catch  any exception, an  Exception object is passed to the except block. We can then alias the exception raised using the "as" clause as shown below:

try:
   
    print(1 / 0)

except Exception as e:
    print(e)

Handle the specific exception

In all the previous examples, we handled all exceptions at once. However this is a bad programming practice and should generally be avoided as it can lead to hard-to-debug errors.

The Pythonic way is to be as explicit as possible by handling only the exception that  you expect to occur.  We achieve this by simply using the except keyword followed by the exception that we want to catch.

try:
   
    print(1 / 0)

except ZeroDivisionError:
    print("You divided a number by 0.")

In the above case we only handle the ZeroDivisionError, any other exception that will occur in the try block will be raised as usual.

#Handle an IndexError
try:
    L = [1, 2, 3, 4]
    print(L[10])

except IndexError:
    print("Invalid index")
#handle a ValueError

#import the math module
import math

try:
   print(math.sqrt(-10))

except ValueError:
    print("Square root of -ve numbers is undefined")

Multiple except blocks

You can have as many except blocks as necessary. This allows you to  handle different kinds of errors that may occur in the same code. However, the try block will be exited when the first exception is encountered

try:
    # statement(s)
except IndexError:
    # statement(s)
except ValueError:
    # statement(s)
import math

try:
   
   #This will raise a TypeError
   print( 3 + 'hello' )

   #This will raise a ValueError
   math.sqrt(-16)

   #This will raise a NameError
   print(a)
   
   #This will raise a zeroDivisionError
   print(10 / 0)

except NameError:
    print("A NameError occurred")

except ValueError: 
    print("A ValueError occurred")

except ZeroDivisionError:
    print("A ZeroDivisionError occurred")

except TypeError:
    print("A TypeError occurred")

The else block

One feature about Python is that it allows the use of the else block in some unexpected places this includes with for and while loops and now with the try blocks. 

The else block when used with the try-except blocks only gets executed if the try block terminates successfully i.e  no exception was raised inside the block.

You can only have one else block in a try-except arrangement and it should be placed after the try and except blocks

import math

try:
   print("Hello, World!")
   print(10/ 2)
   print(math.sqrt(9))

except ValueError:
   print("a valu error has occurred")

except IndexError:
    print("IndexError has occurred")

else:
    print("No Exception was raised.")

The 'finally' block

The finally block  executed gets regardless of whether the try block terminated successfully or an exception was raised.

The block should be placed  as the last block after the try, except  and else blocks. 

try:
    # Some statements.... 

except:
    # Handling of exception (if required)

else:
    # execute if no exception is raised in the try block

finally:
    # Some code .....(always executed)

 

import math

try:
   print("Hello, World!")
   print(10/ 5)
   print(math.sqrt(-9))

except ValueError:
   print("a ValueError has occurred")

except IndexError:
    print("IndexError has occurred")

else:
    print("No Exception was raised.")
finally:
    print("This block always gets executed")

Note: Either the except or the finally block must be present for the try block to work. A SyntaxError will be raised otherwise.