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
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
finally
BlockThe 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
else
BlockThe 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 theexcept
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:
Flow of Control: Refers to how the execution proceeds through different functions and methods.
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 evenmethod1()
.
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 tomethod1()
.Finally,
method1()
handles the error gracefully.
Using raise
to Explicitly Pass the Exception
raise
to Explicitly Pass the ExceptionSometimes, 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 theZeroDivisionError
but decides to pass it up usingraise
.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:
Normal Flow:
method1()
→method2()
→method3()
If no errors occur, the flow returns from
method3()
→method2()
→method1()
.
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.
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
Use Specific Exceptions: Always catch specific exceptions like
ValueError
,IndexError
, orKeyError
instead of a genericException
. This ensures that you’re handling the correct error and not masking other issues.Avoid Catching Everything i.e. Avoid Bare
except
: Usingexcept:
without specifying an exception type catches all exceptions, including system-exiting exceptions likeKeyboardInterrupt
. This can make debugging difficult.Bad Example:
try: result = 10 / 0 except: print("An error occurred")
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
blockRaising Meaningful Exceptions: When raising exceptions, provide meaningful messages that explain the error clearly.
Keep
try
Blocks Small/Short: It’s best to keep yourtry
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 thetry
block. This makes it easier to identify where the error occurred.Use
finally
for Cleanup: Always use thefinally
block to clean up resources such as closing files or releasing network connections.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.
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.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