Chapter 4

Exception Handling in Python

In software development, things can go wrong in various ways, often leading to unexpected behavior. These are commonly known as runtime errors or exceptions. Exception handling is a crucial concept in Python that allows you to manage such runtime errors gracefully, without crashing the program. In this chapter, we will discuss different types of errors, how to handle them, and how to make your code more robust and user-friendly using Python's exception-handling constructs.

In Python, exception handling is a mechanism that allows you to handle runtime errors gracefully. Instead of your program crashing abruptly, you can manage errors in a structured way, ensuring that your code behaves predictably even when something unexpected happens.

In this chapter, we will explore what exceptions are, how to handle them using Python’s built-in constructs, and how to raise custom exceptions. We will also cover important aspects such as try, except, finally, and else blocks, ensuring that all facets of exception handling are clearly understood.


What is an Exception?

There are several scenarios where your Python code might encounter an error. In Python, exceptions are flagged with a specific error type. Each error type corresponds to a different kind of runtime issue:

  • SyntaxError: Raised when the code contains invalid Python syntax. This is a compile-time error that must be fixed before running the code.

    print("Hello  # This will raise a SyntaxError
  • NameError: Raised when you try to use a variable before it has been defined.

    y = 5 * x  # This will raise NameError because x is not defined.
  • ZeroDivisionError: Raised when a number is divided by zero or Performing an invalid arithmetic operation like dividing a number by zero.

    y = 10 / 0  # This will raise ZeroDivisionError.
     = x / z  # If z is 0, this will raise a ZeroDivisionError.
  • IndexError: Raised when you try to access an index that is out of range for a list.

    l = [1, 2, 3]
    print(l[5])  # This will raise IndexError.
  • Invalid Type Conversion: Trying to convert a string that doesn’t represent a valid integer.

    y = int(s)  # If s is not a valid integer, this will raise a ValueError.
  • Using Undefined Variables: Using a variable before it has been defined.

    y = 5 * x  # If x is not defined, this will raise a NameError.
  • Invalid Index in a List: Accessing an index that is out of the valid range in a list.

    y = l[i]  # If i is outside the valid index range, this will raise an IndexError.
  • File Handling Errors: Trying to open a file that doesn’t exist, or trying to write to a file when the disk is full. When attempting to open a file that doesn't exist, Python raises a FileNotFoundError, which we handle gracefully here.

    file = open("non_existent_file.txt")  # If the file doesn't exist, this raises a FileNotFoundError.

These errors, if not anticipated, can terminate your program abruptly. However, Python provides a way to "recover gracefully" from such errors using exception handling. An exception is an error that occurs during the execution of a program. When Python encounters an error, it interrupts the normal flow of the program and raises an exception.


When writing code, it’s important to anticipate potential errors and provide a contingency plan. Python’s exception handling allows you to manage errors as they occur without stopping the entire program. Key Concepts:

  • try block: This is where you write the code that might raise an error.

  • except block: This is where you define what to do if an error occurs.

  • else block: This runs if no errors occur in the try block.

  • finally block: This runs regardless of whether an error occurred or not, typically used for cleanup actions like closing files or releasing resources.

Syntax:

try:
    # Code that may raise an exception
    risky_code()
except ExceptionType:
    # Code to execute if an exception occurs
    handle_exception()

Examples:

Example: Division by Zero

pythonCopy codetry:
    result = 10 / 0
except ZeroDivisionError:
    print("Cannot divide by zero!")

Example: File Not Found

try:
    file = open("nonexistent_file.txt", "r")
except FileNotFoundError:
    print("File not found!")

In above cases, trying to divide a number by zero will raise a ZeroDivisionError or tryig to open a file will raise FileNotFoundError , the program will print a user-friendly message.


Exception Handling with try and except

Let's consider how to handle exceptions using the try, except, else, and finally blocks.

Syntax:

def divide_numbers():
    try:
        # Code that might raise an error
        num1 = int(input("Enter the first number: "))
        num2 = int(input("Enter the second number: "))
        result = num1 / num2
        risky_code()
    except ValueError:
        print("Please enter valid numbers.")
    except ZeroDivisionError:
        print("Division by zero is not allowed.")
    except IndexError:
        # Handle IndexError
        print("Invalid index!")
    except (NameError, KeyError):
        # Handle multiple exceptions
        print("Name or Key error occurred!")
    except:
        # Handle all other exceptions
        print("An unexpected error occurred!")
    else:
        # Runs if no exception occurs
        print("No errors occurred!")
        print(f"Result: {result}")
    finally:
        # Always executed, used for cleanup
        print("Execution complete.")
divide_numbers()

Catching Multiple Exceptions

Sometimes, you might want to handle different types of exceptions differently. Python allows you to catch multiple exceptions by specifying multiple except blocks.

Example:

try:
    number = int(input("Enter a number: "))
    result = 10 / number
except ValueError:
    print("That's not a valid number!")
except ZeroDivisionError:
    print("You cannot divide by zero!")

Output:

Enter a number: 0
You cannot divide by zero!

In this example, the program handles both invalid number inputs (ValueError) and division by zero (ZeroDivisionError).


The finally Block

The finally block is used to specify code that should be executed no matter what. It runs whether an exception occurs or not, making it ideal for cleanup actions like closing files or releasing resources.

Syntax:

try:
    # Code that may raise an exception
    risky_code()
except ExceptionType:
    # Code to handle the exception
    handle_exception()
finally:
    # Code that will always be executed
    cleanup()

Example:

try:
    file = open("example.txt", "r")
    # Do some operations with the file
except FileNotFoundError:
    print("File not found.")
finally:
    print("Closing file.")
    file.close()  # Always close the file

Output:

File not found.
Closing file.

In this case, the finally block ensures that the file is closed, even if an exception is raised.


The else Block

The else block is executed if no exception occurs in the try block. It provides a way to execute code only if everything in the try block runs successfully.

Syntax:

try:
    # Code that may raise an exception
    risky_code()
except ExceptionType:
    # Code to handle the exception
    handle_exception()
else:
    # Code that runs if no exception occurs
    success()

Example:

try:
    number = int(input("Enter a number: "))
    result = 10 / number
except ZeroDivisionError:
    print("Cannot divide by zero!")
except ValueError:
    print("Invalid input!")
else:
    print(f"Result is {result}")

Output:

Enter a number: 2
Result is 5.0

In this example, the else block runs only when no exceptions are raised in the try block.

Using Exception Positively

In some cases, exceptions can be used proactively to control the flow of a program rather than just dealing with errors. In the previous example (Code 3), we avoided the need for an explicit check (if b in scores.keys()) by using exception handling to address the KeyError when it occurred. This is an example of using exceptions "positively," as they are part of normal program flow.

Traditional Approach:

Consider a scenario where we are storing cricket player scores in a dictionary, where each player’s name is a key, and the scores are stored in a list as values. We want to update the scores by either appending to an existing player's list or creating a new entry if the player doesn't exist.

Code : Without exception handling

scores = {"Shefali": [3, 22], "Harmanpreet": [200, 3]}
b = "Smriti"
s = 50
if b in scores.keys():
    scores[b].append(s)  # Append score to existing list
else:
    scores[b] = [s]  # Create a new entry

In this case, the code checks if the player already exists in the dictionary before appending the new score. This works, but it can be improved with exception handling.


Code: Using Exceptions:

A more efficient way to handle the same task is by using exception handling. Instead of explicitly checking if a key exists, we can rely on a KeyError to trigger when trying to append to a non-existent key.

Code 3 (with exception handling):

scores = {"Shefali": [3, 22], "Harmanpreet": [200, 3]}
b = "Smriti"
s = 50
try:
    scores[b].append(s)  # Try appending to the player's score list
except KeyError:
    scores[b] = [s]  # If player not found, create a new entry

In this example:

  • If the key exists in the dictionary, the score is appended.

  • If the key does not exist, a KeyError is raised, and the code in the except block is executed to create a new entry for the player.


Raising Exceptions

In Python, you can also raise exceptions deliberately using the raise keyword. Sometimes, you may want to raise an exception explicitly using the raise keyword. This is useful when you want to signal that something unexpected has occurred, even if Python itself hasn’t raised an exception.

Example:

def validate_age(age):
    if age < 18:
        raise ValueError("Age must be 18 or above.")
    return True

try:
    validate_age(16)
except ValueError as e:
    print(e)

In this case, we manually raise a ValueError if the age is less than 18.

Example:

def validate_age(age):
    if age < 18:
        raise ValueError("Age must be 18 or above.")
    return True

try:
    validate_age(16)
except ValueError as e:
    print(e)

Output:

Age must be 18 or above.

Here, the raise keyword manually triggers a ValueError when the age is below 18.


Custom Exceptions

In Python, you can define your own exceptions by subclassing the built-in Exception class. Custom exceptions allow you to create meaningful errors that are specific to your application.

Example:

class InvalidAgeError(Exception):
    pass

def validate_age(age):
    if age < 18:
        raise InvalidAgeError("Age must be 18 or above.")
    return True

try:
    validate_age(16)
except InvalidAgeError as e:
    print(e)

Output:

Age must be 18 or above.

In this case, we created a custom exception InvalidAgeError and used it to raise an error when the age is invalid.


Handling Multiple Exceptions in a Single Block

Python allows multiple exceptions to be handled in a single except block by grouping them in a tuple.

Example:

try:
    number = int(input("Enter a number: "))
    result = 10 / number
except (ValueError, ZeroDivisionError) as e:
    print(f"An error occurred: {e}")

Output:

Enter a number: 0
An error occurred: division by zero

This approach simplifies the code when handling multiple exceptions with similar behavior.


In Python, when a method calls another method, which in turn calls another method, there is a flow of control between the different functions or methods in the call stack. When an exception occurs in one of the methods, it disrupts the normal flow of the program. However, Python provides mechanisms to catch and handle these exceptions at various points in the call chain, either at the point where the error occurs or by passing it up the chain to be handled at a higher level. This is particularly useful in complex programs where multiple functions depend on each other.

Key Concepts:

  1. Flow of Control: Refers to how the execution proceeds through different functions and methods.

  2. Exception Propagation: When an exception is raised in one function, it can propagate (be passed) up the call stack until it is handled.


Flow of Control with Multiple Method Calls

In Python, the flow of control between methods allows for a structured way of executing code. Exception handling plays a vital role in ensuring that runtime errors don’t crash the entire program but are instead managed gracefully. By handling exceptions at appropriate levels of the call chain, you can ensure that the program remains robust and user-friendly. The ability to propagate exceptions up the call stack gives developers flexibility in where and how to handle errors, making Python's exception handling a powerful tool in writing reliable software.

When a method calls another method, and that method calls yet another, the control flows sequentially from one method to another. In case of an error or exception, Python allows you to handle the error at the level where it occurs, or you can pass the error up the call chain to a method higher in the stack.

Example Scenario: Method Calling Another Method

Here’s a basic example to demonstrate flow of control:

def method1():
    print("In method1")
    method2()

def method2():
    print("In method2")
    method3()

def method3():
    print("In method3")
    result = 10 / 0  # This will raise ZeroDivisionError

When method1() is called, it in turn calls method2(), which calls method3(). The error (ZeroDivisionError) occurs in method3() because dividing by zero is not allowed.

  • Without exception handling, this error will propagate through the call stack, stopping the entire program.

  • We can handle this error at different levels—either in method3(), method2(), or even method1().


Handling the Exception Locally

One option is to handle the exception at the place where it occurs, i.e., in method3() itself.

def method3():
    print("In method3")
    try:
        result = 10 / 0  # This raises ZeroDivisionError
    except ZeroDivisionError:
        print("Error handled in method3: Cannot divide by zero.")

In this case, the exception is handled directly in method3(), so the error doesn’t propagate upwards, and the program continues without breaking.


Raising the Exception to the Calling Method

If we don’t want to handle the exception locally in method3(), we can raise the exception so that the calling method (method2()) can handle it. This is called exception propagation.

def method3():
    print("In method3")
    result = 10 / 0  # No handling here, the exception will propagate up

def method2():
    print("In method2")
    try:
        method3()
    except ZeroDivisionError:
        print("Error handled in method2: Cannot divide by zero.")

Here, method3() doesn’t handle the exception, so it propagates up to method2(), which catches the error and handles it. The flow of control goes back up to method2() where the exception is addressed.


Raising the Exception Further Up

You can continue to propagate the exception further up the call chain if needed, all the way to method1().

def method3():
    print("In method3")
    result = 10 / 0  # Error raised here

def method2():
    print("In method2")
    method3()  # No handling here, propagate further up

def method1():
    print("In method1")
    try:
        method2()
    except ZeroDivisionError:
        print("Error handled in method1: Cannot divide by zero.")

In this case:

  • The exception is raised in method3().

  • It is not handled in method2(), so it propagates further up to method1().

  • Finally, method1() handles the error gracefully.


Using raise to Explicitly Pass the Exception

Sometimes, you may want to explicitly pass the exception up the call stack using the raise keyword. This allows you to capture additional information or perform cleanup before passing the error up.

def method3():
    print("In method3")
    try:
        result = 10 / 0  # This raises ZeroDivisionError
    except ZeroDivisionError as e:
        print("Caught an error in method3, passing it up.")
        raise  # Re-raise the exception to propagate it

def method2():
    print("In method2")
    method3()  # No handling here, the error will propagate

def method1():
    print("In method1")
    try:
        method2()
    except ZeroDivisionError:
        print("Error finally handled in method1.")

In this example:

  • method3() catches the ZeroDivisionError but decides to pass it up using raise.

  • method2() does not handle the exception, so it continues to propagate.

  • method1() finally handles the error.


Flow of Control with Exception Handling: Here’s a high-level flow of how the control moves through the methods and how exceptions can be handled:

  1. Normal Flow:

    • method1() → method2() → method3()

    • If no errors occur, the flow returns from method3() → method2() → method1().

  2. Exception Occurs in method3():

    • The exception disrupts the normal flow.

    • Depending on where the error is handled, the flow either stops at that point or continues up the chain.

  3. Handling the Exception:

    • The error can be handled in any of the methods, but if not handled locally, it will propagate upwards until it finds a handler.

    • Once handled, the program flow continues normally from the point where the exception was caught.


Best Practices for Exception Handling

  1. Use Specific Exceptions: Always catch specific exceptions like ValueError, IndexError, or KeyError instead of a generic Exception. This ensures that you’re handling the correct error and not masking other issues.

  2. Avoid Catching Everything i.e. Avoid Bare except: Using except: without specifying an exception type catches all exceptions, including system-exiting exceptions like KeyboardInterrupt. This can make debugging difficult.

    Bad Example:

    try:
        result = 10 / 0
    except:
        print("An error occurred")
  3. Ensure Proper Cleanup: If a method opens a file or resource, make sure it closes it, even if an exception occurs. This can be done using the finally block

  4. Raising Meaningful Exceptions: When raising exceptions, provide meaningful messages that explain the error clearly.

  5. Keep try Blocks Small/Short: It’s best to keep your try blocks as small as possible. This makes it easier to identify which statement caused the exception.Enclose only the code that might raise an exception in the try block. This makes it easier to identify where the error occurred.

  6. Use finally for Cleanup: Always use the finally block to clean up resources such as closing files or releasing network connections.

  7. Handle Exceptions Where It Makes Sense: Handle the exception as close to where it occurs as possible. If a function knows how to handle its own errors, it should do so. Otherwise, let the caller handle it.

  8. Use raise Wisely: If a function can't fully handle an error but has some context about it (e.g., additional logging or cleanup), catch the error, then re-raise it to the calling method.

  9. Propagate Critical Errors Up: Some errors, like hardware failures or invalid configurations, should be propagated up to the highest level for proper handling.


Conclusion

Exception handling in Python is a powerful tool that allows you to write robust and fault-tolerant programs. By anticipating potential errors and handling them gracefully, you can prevent unexpected crashes and provide meaningful feedback to users. Always remember to handle exceptions at the appropriate level in your code, use specific exception types, and clean up resources to ensure smooth execution of your programs.

Exception handling is a vital feature in Python that helps you create robust and error-tolerant programs. By anticipating potential errors and writing contingency plans, you ensure that your program can recover gracefully from unexpected conditions. Understanding how to handle exceptions effectively will allow you to write cleaner, safer, and more reliable code.

Note: Explore more advanced Python features such as decorators and context managers, which build on concepts like exception handling to simplify and enhance code readability.


Last updated