Error Handling & Exceptions


Countless programs crash and burn due to errors. But fear not! This section is all about equipping you with the knowledge and tools to gracefully handle these hiccups, making your code more robust and user-friendly. In the world of Python programming, mastering error handling is crucial for creating reliable applications. Let's dive deep into how to debug, prevent, and manage unexpected situations that might arise during program execution.

 

Understanding Errors in Python

In Python, errors are broadly categorized into two main types: Syntax Errors and Runtime Errors (Exceptions). Understanding the difference is the first step towards effective error management.

 

Syntax Errors (Parsing Errors)

Note: Often referred to as "parsing errors" or "compiler errors."

Syntax errors are the most basic type of error. They occur when the Python interpreter encounters code that does not conform to the language's grammar rules. Think of it like a typo in a spoken language – if you say "Me go store," it's grammatically incorrect, and people might not understand you. Similarly, if your Python code has a syntax error, the interpreter can't understand what you're trying to do, and it won't even try to run the program. The interpreter will point to the line where the error occurred, often with a ^ symbol.

 

Key Characteristics:

Occur before the program starts executing.

Prevent the program from running at all.

Are usually easy to spot and fix, as the interpreter tells you exactly where the problem is.

 

Code Examples:

Beginner Friendly (Missing Colon):

Python

 

# python beginners, common syntax error, missing colon, if statement error
# Example 1: Missing colon in an if statement
if 5 > 2  # Missing a colon here!
    print("5 is greater than 2")

Explanation: This code will raise a SyntaxError because the if statement is missing the crucial colon (:) that signifies the start of the code block.

 

Intermediate (Mismatched Parentheses):

Python

 

# python programming, debugging, mismatched parentheses, syntax error example
# Example 2: Mismatched parentheses in a print statement
print("Hello, world!"  # Missing a closing parenthesis

Explanation: Here, a SyntaxError will occur because the print function call is missing its closing parenthesis ). The interpreter expects a balanced set of parentheses.

 

Advanced (Incorrect Keyword Usage):

Python

 

# python advanced, syntax, incorrect keyword, class definition error
# Example 3: Incorrect keyword usage in a class definition
clss MyClass: # 'clss' is not a valid keyword, should be 'class'
    def __init__(self):
        self.value = 10

Explanation: This example demonstrates a SyntaxError due to a typo in the class keyword. Python expects specific keywords for defining language constructs.

 

Advanced (Invalid Assignment Target):

Python

 

# python best practices, syntax errors, invalid assignment, assignment error
# Example 4: Invalid assignment target
10 = x # Cannot assign to a literal

Explanation: You cannot assign a value to a literal (like the number 10). The left-hand side of an assignment operator (=) must be a valid variable name or an assignable expression. This results in a SyntaxError.

 

Advanced (Non-printable Character):

Python

 

# python tips, hidden characters, syntax error, non-printable characters
# Example 5: Non-printable character (often invisible)
# This example requires pasting a special non-printable character.
# For demonstration purposes, let's represent it conceptually.
# Imagine a hidden character like a zero-width space here:
print("Hello World"​;) # The character between "World" and ";" is a non-printable character

Explanation: While not directly typeable as shown, sometimes invisible, non-printable characters (like a zero-width space or an invalid unicode character) can be accidentally introduced into your code, leading to a SyntaxError because the interpreter doesn't recognize them as valid parts of the syntax. This is rare but can be frustrating to debug.

 

Runtime Errors (Exceptions)

Note: Also known as "exceptions" or "program crashes."

Runtime errors, or exceptions, occur during the execution of a program. This means the Python interpreter successfully parsed your code, but something went wrong while the program was actually running. For instance, you might try to divide by zero, access a list element that doesn't exist, or open a file that isn't there. When a runtime error occurs, the program normally stops its execution, and Python provides a "traceback" that gives you information about where the error occurred and what type of exception it was.

 

Key Characteristics:

Occur after the program starts executing.

Can cause the program to terminate abruptly if not handled.

Are often caused by unexpected conditions or invalid operations.

Require specific error handling mechanisms to prevent program crashes.

 

Code Examples:

Beginner Friendly (ZeroDivisionError):

Python

 

# python for beginners, runtime error, ZeroDivisionError, division by zero
# Example 1: Division by zero
numerator = 10
denominator = 0
result = numerator / denominator # This will cause a ZeroDivisionError
print(result)

Explanation: This code attempts to divide a number by zero, which is mathematically undefined. Python raises a ZeroDivisionError at runtime because this operation is not permitted.

 

Intermediate (IndexError):

Python

 

# python programming, common exceptions, IndexError, list out of bounds
# Example 2: Accessing an out-of-bounds index in a list
my_list = [1, 2, 3]
print(my_list[3]) # This will cause an IndexError

Explanation: Lists in Python are zero-indexed. This code tries to access the element at index 3 in my_list, but the valid indices are 0, 1, and 2. Attempting to access an invalid index results in an IndexError.

 

Advanced (FileNotFoundError):

Python

 

# python file handling, FileNotFoundError, common errors, runtime exception
# Example 3: Trying to open a non-existent file
try:
    with open("non_existent_file.txt", "r") as file:
        content = file.read()
        print(content)
except FileNotFoundError:
    print("Error: The file was not found.")

Explanation: This code attempts to open a file named "non_existent_file.txt" in read mode. If the file does not exist, a FileNotFoundError will be raised at runtime. We've included a try-except block here to demonstrate how you might gracefully handle this specific error, preventing the program from crashing.

 

Advanced (TypeError):

Python

 

# python TypeError, data type mismatch, runtime error, string concatenation
# Example 4: Performing an operation on incompatible data types
num = 10
text = "hello"
combined = num + text # This will cause a TypeError
print(combined)

Explanation: Python is a strongly typed language. This code tries to add an integer (num) to a string (text). Since these types are incompatible for the addition operation, a TypeError is raised.

 

Advanced (KeyError):

Python

 

# python dictionary errors, KeyError, missing dictionary key, runtime exception
# Example 5: Accessing a non-existent key in a dictionary
my_dict = {"name": "Alice", "age": 30}
print(my_dict["city"]) # This will cause a KeyError

Explanation: This code attempts to access a key named "city" in my_dict. Since "city" is not a key present in the dictionary, a KeyError is raised at runtime.

 

 

Handling Exceptions in Python

Handling exceptions is about anticipating potential runtime errors and writing code to deal with them gracefully, rather than letting your program crash. This makes your applications more robust and provides a better user experience. The primary mechanism for handling exceptions in Python is the try, except block.

 

The try, except block

Note: Core of error handling, ensures program resilience.

The try, except block is a fundamental construct for handling exceptions. Code that might raise an exception is placed inside the try block. If an exception occurs within the try block, the execution of the try block is immediately stopped, and Python looks for a matching except block. If a matching except block is found, the code inside that block is executed. If no exception occurs in the try block, the except block is skipped.

Key Concept: "It's easier to ask for forgiveness than permission." Try to do what you intend, and if it fails, handle the failure.

 

Code Examples:

Beginner Friendly (Basic ZeroDivisionError Handling):

Python

 

# python beginner tutorial, try except block, handle ZeroDivisionError
# Example 1: Basic handling of ZeroDivisionError
try:
    numerator = 10
    denominator = 0
    result = numerator / denominator
    print(f"The result is: {result}")
except ZeroDivisionError:
    print("Error: Cannot divide by zero!")
print("Program continues after error handling.")

Explanation: The code that might cause ZeroDivisionError is in the try block. If ZeroDivisionError occurs, the except ZeroDivisionError block is executed, printing an informative message instead of crashing. The program then continues execution.

 

Intermediate (Handling ValueError in User Input):

Python

 

# python user input, ValueError, error handling examples, robust code
# Example 2: Handling ValueError for invalid user input
try:
    age_str = input("Enter your age: ")
    age = int(age_str) # This might raise a ValueError if input is not a number
    print(f"You are {age} years old.")
except ValueError:
    print("Error: Invalid input. Please enter a valid number for your age.")

Explanation: We ask the user for input, which is initially a string. We attempt to convert it to an integer using int(). If the user enters something that cannot be converted to an integer (e.g., "abc"), a ValueError is raised, and the except ValueError block provides user-friendly feedback.

 

Advanced (Handling Multiple Potential Errors):

Python

 

# python exception handling, multiple exceptions, try except, file operations
# Example 3: Handling multiple potential errors in a single try-except structure
file_name = "data.txt"
try:
    with open(file_name, 'r') as file:
        data = file.read()
        numbers = [int(x) for x in data.split(',')] # Potential ValueError
        average = sum(numbers) / len(numbers) # Potential ZeroDivisionError if numbers is empty
        print(f"Average of numbers: {average}")
except FileNotFoundError:
    print(f"Error: The file '{file_name}' was not found.")
except ValueError:
    print("Error: Data in the file is not correctly formatted (expected comma-separated numbers).")
except ZeroDivisionError:
    print("Error: The file contains no numbers, so average cannot be calculated.")
except Exception as e: # Catch-all for any other unexpected errors
    print(f"An unexpected error occurred: {e}")

Explanation: This example demonstrates handling multiple specific exceptions within one try block. It attempts to read numbers from a file, convert them to integers, and calculate their average. It specifically handles FileNotFoundError, ValueError (if numbers aren't parseable), and ZeroDivisionError (if the file is empty or contains no valid numbers). It also includes a generic except Exception as e to catch any other unforeseen errors.

 

Advanced (Nested Try-Except Blocks):

Python

 

# python advanced exception handling, nested try except, robust programming
# Example 4: Nested try-except blocks for granular error control
def divide_user_input():
    try:
        num1_str = input("Enter the first number: ")
        try:
            num1 = float(num1_str)
        except ValueError:
            print("Invalid input for the first number. Please enter a valid number.")
            return

        num2_str = input("Enter the second number: ")
        try:
            num2 = float(num2_str)
        except ValueError:
            print("Invalid input for the second number. Please enter a valid number.")
            return

        result = num1 / num2
        print(f"Result of division: {result}")
    except ZeroDivisionError:
        print("Error: Cannot divide by zero!")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")

divide_user_input()

Explanation: Here, we have nested try-except blocks. The outer try handles ZeroDivisionError for the division operation. The inner try-except blocks are specifically for converting user input to floats, allowing for more precise error messages related to invalid number formats, independent of the division itself.

 

Advanced (Using else with try-except):

Python

 

# python try except else, successful execution block, error handling
# Example 5: Using the else block with try-except
def process_data(data):
    try:
        # Simulate an operation that might fail
        if not isinstance(data, list):
            raise TypeError("Input must be a list.")
        
        if not data:
            raise ValueError("List cannot be empty.")
            
        processed_data = [item * 2 for item in data] # Example processing
    except TypeError as e:
        print(f"Caught a TypeError: {e}")
        return None
    except ValueError as e:
        print(f"Caught a ValueError: {e}")
        return None
    except Exception as e:
        print(f"An unexpected error occurred during processing: {e}")
        return None
    else:
        # This block executes ONLY if no exception occurred in the try block
        print("Data processed successfully!")
        return processed_data

print(process_data([1, 2, 3]))
print(process_data([]))
print(process_data("hello"))

Explanation: This example introduces the else block. The code inside the else block only executes if the try block completes without raising any exceptions. This is useful for code that should only run if the initial, potentially error-prone operation was successful, promoting cleaner separation of concerns.

 

 

Handling Specific Exceptions

Note: Targeted error handling, avoids masking errors.

It's generally considered good practice to handle specific exceptions rather than using a generic except block for all errors. Handling specific exceptions allows you to provide more precise error messages and implement different recovery strategies based on the type of error that occurred. You can specify multiple except blocks for different exception types. Python will execute the first except block that matches the raised exception.

 

Code Examples:

Beginner Friendly (Specific ZeroDivisionError):

Python

 

# python specific exception, ZeroDivisionError, error handling basics
# Example 1: Handling a specific ZeroDivisionError
try:
    result = 10 / 0
except ZeroDivisionError:
    print("You tried to divide by zero! Please provide a non-zero denominator.")

Explanation: This code explicitly catches only ZeroDivisionError. If any other type of error occurred, it would not be caught by this except block and would cause the program to crash (unless there was another except block or a generic one).

 

Intermediate (Handling ValueError and TypeError Separately):

Python

 

# python handling multiple exceptions, ValueError, TypeError, error specific
# Example 2: Handling ValueError and TypeError separately
def process_input(value):
    try:
        num = int(value)
        print(f"Successfully converted to integer: {num}")
    except ValueError:
        print(f"Error: '{value}' cannot be converted to an integer.")
    except TypeError:
        print(f"Error: Input '{value}' has an unsupported type for conversion.")

process_input("123")
process_input("hello")
process_input([1, 2]) # This will raise a TypeError

Explanation: This function attempts to convert value to an integer. It distinguishes between ValueError (if the string content isn't a valid number) and TypeError (if the input itself isn't a string or compatible type).

 

Advanced (Multiple Except Blocks with Inheritance):

Python

 

# python exception hierarchy, multiple except blocks, specific to generic
# Example 3: Handling exceptions with an understanding of exception hierarchy
def process_file_data(filename):
    try:
        with open(filename, 'r') as file:
            content = file.read()
            numbers = [float(x) for x in content.split(',')]
            print(f"Numbers from file: {numbers}")
    except FileNotFoundError:
        print(f"Error: The file '{filename}' does not exist.")
    except (ValueError, IndexError) as e: # Catch multiple specific exceptions in one block
        print(f"Error processing file data: Invalid format or missing data. Details: {e}")
    except Exception as e: # Catch any other general exception
        print(f"An unexpected error occurred while processing '{filename}': {e}")

# Create some dummy files for testing
with open("valid_data.txt", "w") as f:
    f.write("10.5,20.0,30.2")
with open("invalid_data.txt", "w") as f:
    f.write("a,b,c")

process_file_data("non_existent.txt")
process_file_data("valid_data.txt")
process_file_data("invalid_data.txt")

Explanation: This example demonstrates catching multiple specific exceptions (ValueError, IndexError) in a single except clause. It also shows the practice of putting more specific exceptions (like FileNotFoundError) before more general ones (Exception) to ensure targeted handling.

 

Advanced (Using sys.exc_info for detailed error info):

Python

 

# python advanced error handling, sys.exc_info, detailed exception info
import sys

# Example 4: Getting detailed exception information using sys.exc_info()
def safe_division(a, b):
    try:
        result = a / b
        return result
    except Exception:
        exc_type, exc_value, exc_traceback = sys.exc_info()
        print(f"An error occurred:")
        print(f"  Type: {exc_type.__name__}")
        print(f"  Value: {exc_value}")
        # In a real application, you might log exc_traceback
        # traceback.print_tb(exc_traceback) # Uncomment to print full traceback
        return None

print(safe_division(10, 2))
print(safe_division(10, 0))
print(safe_division("abc", 5)) # This will raise a TypeError caught by generic Exception

Explanation: While a generic except Exception as e is often sufficient, sys.exc_info() can be used within an except block to retrieve more granular details about the exception, including its type, value (the exception instance itself), and a traceback object. This is useful for logging or more advanced debugging.

 

Advanced (Registering a custom handler for unhandled exceptions - not recommended for production):

Python

 

# python custom exception handler, unhandled exceptions, sys.excepthook (caution)
import sys
import logging

# Example 5: Custom exception hook (advanced, use with caution)
# Note: This is usually for debugging or very specific logging, not standard error handling.
# Overriding sys.excepthook can hide problems if not used carefully.

logging.basicConfig(level=logging.ERROR, format='%(asctime)s - %(levelname)s - %(message)s')

def custom_exception_handler(exc_type, exc_value, exc_traceback):
    """
    A custom handler for unhandled exceptions.
    This will be called for any exception that is not caught by a try-except block.
    """
    logging.error(f"Unhandled Exception Caught!")
    logging.error(f"Type: {exc_type.__name__}")
    logging.error(f"Value: {exc_value}")
    # You might log the traceback for debugging
    # import traceback
    # logging.error("Traceback:\n" + "".join(traceback.format_tb(exc_traceback)))
    print("\nAn unexpected error occurred! Please contact support.")

# Register our custom handler
sys.excepthook = custom_exception_handler

print("Starting program...")
# Simulate some code that will raise an unhandled exception
x = 10
y = 0
# This line will cause an unhandled ZeroDivisionError, which our custom hook will catch
# z = x / y

print("Program finished (if no unhandled error).")
# To demonstrate an unhandled error, uncomment the line above
# z = x / y

Explanation: This is a very advanced concept. sys.excepthook allows you to register a function that will be called whenever an unhandled exception occurs (i.e., an exception that wasn't caught by any try-except block). While powerful for global logging or specific shutdown procedures, it's generally not recommended for routine error handling as it can obscure the normal flow of exception propagation.

 

 

The else block

Note: Executes only if no exception occurs in try.

The else block in a try-except statement is executed only if no exception occurs in the corresponding try block. It provides a way to put code that depends on the successful execution of the try block without being caught by the except clause. This can make your code cleaner and more readable by separating the "normal" execution path from the error-handling path.

 

Code Examples:

Beginner Friendly (Confirming Successful Conversion):

Python

 

# python try except else, beginner error handling, successful operation
# Example 1: Confirming successful integer conversion
user_input = input("Enter a number: ")
try:
    num = int(user_input)
except ValueError:
    print("Error: That was not a valid number.")
else:
    # This code runs only if int(user_input) was successful
    print(f"Successfully converted '{user_input}' to integer: {num}")
    print("Calculation result:", num * 2)

Explanation: If the int(user_input) conversion in the try block is successful (i.e., no ValueError is raised), the code in the else block will execute, confirming the conversion and performing a calculation. If ValueError occurs, only the except block runs.

 

Intermediate (File Operation Success Confirmation):

Python

 

# python file operations, try except else, file writing success
# Example 2: Confirming successful file writing
filename = "my_data.txt"
data_to_write = "Hello Python world!\n"

try:
    with open(filename, 'w') as file:
        file.write(data_to_write)
except IOError as e:
    print(f"Error writing to file: {e}")
else:
    # This runs only if file.write was successful
    print(f"Successfully wrote data to '{filename}'.")
    # You could then perform further actions that depend on the file existing
    try:
        with open(filename, 'r') as file:
            read_data = file.read()
            print(f"Read back from file: '{read_data.strip()}'")
    except FileNotFoundError:
        print("Error: File was not found after writing (unexpected).")

Explanation: The else block here contains code that verifies the file operation. If file.write() completes without an IOError, it confirms the successful write and then proceeds to read the data back, ensuring it's available.

 

Advanced (Resource Management and Post-Processing):

Python

 

# python advanced exception handling, try except else finally, resource management
# Example 3: Resource management with post-processing in else
def fetch_and_process_resource(resource_id):
    resource = None # Initialize to None
    try:
        # Simulate acquiring a resource that might fail
        if resource_id == "invalid":
            raise ValueError("Invalid resource ID provided.")
        elif resource_id == "unavailable":
            raise ConnectionError("Resource temporarily unavailable.")

        # Simulate resource acquisition
        resource = f"Resource data for {resource_id}"
        print(f"Resource '{resource_id}' acquired.")

    except ValueError as e:
        print(f"Error: {e}")
    except ConnectionError as e:
        print(f"Network error: {e}")
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
    else:
        # This code runs only if resource acquisition was successful
        print(f"Processing resource: '{resource}'")
        # Perform operations that depend on the resource being available
        processed_result = resource.upper()
        print(f"Processed result: '{processed_result}'")
        return processed_result
    finally:
        # This block will always execute, regardless of whether an exception occurred
        if resource:
            print(f"Releasing resource for '{resource_id}'.") # Simulate resource release

    return None

fetch_and_process_resource("valid_resource")
print("-" * 20)
fetch_and_process_resource("invalid")
print("-" * 20)
fetch_and_process_resource("unavailable")

Explanation: In this more complex example, the else block is used to perform operations that depend on the successful acquisition of a "resource." If any error occurs during acquisition in the try block, the else block is skipped. The finally block (discussed next) ensures resource cleanup regardless of success or failure.

 

Advanced (Conditional Execution after Success):

Python

 

# python best practices, try except else, conditional logic
# Example 4: Conditional execution based on try success
def validate_and_save_settings(settings):
    try:
        # Simulate validation of settings
        if not isinstance(settings, dict):
            raise TypeError("Settings must be a dictionary.")
        if "timeout" not in settings or not isinstance(settings["timeout"], (int, float)):
            raise ValueError("Timeout setting is missing or invalid.")
        if settings["timeout"] < 0:
            raise ValueError("Timeout cannot be negative.")

        print("Settings validated successfully.")
        # Simulate saving settings (e.g., to a file or database)
        # For this example, just print
        # save_to_database(settings)
        # save_to_file(settings)

    except (TypeError, ValueError) as e:
        print(f"Validation Error: {e}")
        return False
    except Exception as e:
        print(f"An unexpected error occurred during validation: {e}")
        return False
    else:
        # This block runs only if all validation checks passed
        print("Settings are valid and ready to be used or saved.")
        print(f"Current settings: {settings}")
        return True

validate_and_save_settings({"timeout": 30, "retries": 5})
validate_and_save_settings({"retries": 5}) # Missing timeout
validate_and_save_settings("not a dict") # Wrong type

Explanation: This function validates a dictionary of settings. The else block is crucial here because it contains code that should only execute if all the validation checks within the try block pass without raising any TypeError or ValueError. If validation fails, the else block is skipped, and the appropriate except block handles the error.

 

Advanced (Combining else with a loop for repeated attempts):

Python

 

# python loops, try except else, robust input loop, user friendly
# Example 5: Using else with a loop for successful input acquisition
def get_positive_number():
    while True: # Loop indefinitely until valid input is received
        user_input = input("Please enter a positive number: ")
        try:
            num = float(user_input)
            if num <= 0:
                raise ValueError("Number must be positive.")
        except ValueError as e:
            print(f"Invalid input: {e}. Please try again.")
        except Exception as e:
            print(f"An unexpected error occurred: {e}. Please try again.")
        else:
            # If no exception occurred and num is positive, we break the loop
            print(f"You entered a valid positive number: {num}")
            return num

get_positive_number()

Explanation: This example uses a while True loop to continuously prompt the user for input until a valid positive number is entered. The else block is key here: if the try block successfully converts the input to a float and passes the positive number check, then the else block executes, prints a success message, and return num breaks out of the infinite loop. If an error occurs, the except block prints an error message, and the loop continues, asking for input again.

 

 

The finally block

Note: Guarantees code execution, essential for cleanup.

The finally block in a try-except statement is guaranteed to execute, regardless of whether an exception occurred in the try block or not. This makes finally the perfect place for cleanup operations that must always happen, such as closing files, releasing network connections, or cleaning up system resources. Even if an unhandled exception propagates out of the try or except block, the finally block will still run before the program terminates.

Key Concept: "Always do this cleanup, no matter what."

 

Code Examples:

Beginner Friendly (Guaranteed Resource Closure - simplified):

Python

 

# python finally block, guaranteed execution, resource cleanup, basic example
# Example 1: Ensuring a "resource" is always closed
def process_data(data):
    file_open = False
    try:
        print(f"Attempting to process: {data}")
        # Simulate opening a file
        print("File opened.")
        file_open = True

        if data == "error":
            raise ValueError("Simulating a data processing error.")

        print("Data processed successfully.")
    except ValueError as e:
        print(f"Caught error: {e}")
    finally:
        # This block always executes
        if file_open:
            print("File closed.")
        print("Cleanup operations complete.")

process_data("good_data")
print("-" * 20)
process_data("error")

Explanation: In this simplified example, the finally block ensures that "File closed." is always printed, simulating a resource being released, whether process_data finishes successfully or an error is caught. The file_open flag is used to ensure we only try to "close" it if it was "opened".

 

Intermediate (File Handle Closure with with statement and finally):

Python

 

# python file handling, finally block, with statement, guaranteed close
# Example 2: Using finally for file closure (though 'with' is often preferred)
# Note: The 'with' statement is generally the preferred way for file handling,
# as it automatically ensures file closure. However, finally can be used
# for resources not managed by 'with' or for additional cleanup.

file_object = None
try:
    file_object = open("my_log.txt", "w") # Open a file
    file_object.write("This is a log entry.\n")
    # Simulate an error
    # raise Exception("Simulating a generic error.")
    print("Data written successfully.")
except Exception as e:
    print(f"An error occurred: {e}")
finally:
    # This block will always run, ensuring the file is closed
    if file_object:
        file_object.close()
        print("File 'my_log.txt' has been closed.")

Explanation: This example shows how finally ensures that file_object.close() is called, guaranteeing that the file resource is released, even if an exception occurs during the try block's execution. It also highlights the with statement's preference for simpler file handling.

 

Advanced (Database Connection Cleanup):

Python

 

# python database connection, finally block, resource management, cleanup
# Example 3: Ensuring database connection is closed
import sqlite3

conn = None # Initialize connection to None
try:
    conn = sqlite3.connect(':memory:') # Connect to an in-memory database
    cursor = conn.cursor()
    cursor.execute("CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)")
    cursor.execute("INSERT INTO users (name) VALUES (?)", ("Alice",))
    print("Database operation successful.")
    # Simulate an error during a subsequent operation
    # cursor.execute("INSERT INTO non_existent_table (name) VALUES (?)", ("Bob",))
except sqlite3.Error as e:
    print(f"Database error occurred: {e}")
finally:
    # This block ensures the connection is closed, whether successful or not
    if conn:
        conn.close()
        print("Database connection closed.")

Explanation: This demonstrates a crucial use case for finally: ensuring that a database connection is always closed. Whether the database operations succeed or fail with an sqlite3.Error, the finally block guarantees that conn.close() is called, preventing resource leaks.

 

Advanced (Network Socket Cleanup):

Python

 

# python network programming, socket cleanup, finally block, robust code
# Example 4: Ensuring network socket closure
import socket

sock = None # Initialize socket to None
try:
    sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    sock.settimeout(5) # Set a timeout
    sock.connect(("localhost", 80)) # Try to connect to a local server (might fail)
    print("Connected to server.")
    sock.sendall(b"Hello from client!")
    response = sock.recv(1024)
    print(f"Received: {response.decode()}")
except socket.timeout:
    print("Connection timed out.")
except ConnectionRefusedError:
    print("Connection refused. Is the server running?")
except Exception as e:
    print(f"An unexpected socket error occurred: {e}")
finally:
    # Always close the socket if it was opened
    if sock:
        sock.close()
        print("Socket closed.")

Explanation: Similar to database connections, network sockets are vital resources. The finally block ensures that sock.close() is invoked, releasing the network resource, irrespective of whether the connection was successful, timed out, or encountered any other socket.error.

 

Advanced (Cleanup with a more complex control flow, e.g., a loop with break):

Python

 

# python complex finally, loop control, resource cleanup, advanced example
# Example 5: `finally` ensuring cleanup even with complex control flow (e.g., break in loop)
def process_batches(data_list):
    resource_acquired = False
    try:
        # Simulate acquiring a global resource
        print("Acquiring global processing resource...")
        resource_acquired = True

        for i, data_item in enumerate(data_list):
            try:
                if data_item == "stop":
                    print("Stop signal received. Exiting batch processing.")
                    break # Break out of the loop
                if data_item == "error":
                    raise ValueError(f"Error in item {i}: '{data_item}'")

                print(f"Processing item {i}: {data_item}")
            except ValueError as e:
                print(f"Skipping item {i} due to error: {e}")
            # Note: No 'finally' for the inner try here, as the outer one handles global cleanup

    except Exception as e:
        print(f"An unexpected error in batch processing: {e}")
    finally:
        # This will always run, even if 'break' was hit or an outer exception occurred
        if resource_acquired:
            print("Releasing global processing resource.")

process_batches(["item1", "item2", "error", "item3", "stop", "item4"])
print("-" * 20)
process_batches(["item_A", "item_B"])

Explanation: This sophisticated example demonstrates that the finally block is executed even when control flow leaves the try block due to a break statement within a loop, or if a top-level exception occurs. It emphasizes that finally is the ultimate guarantee for cleanup actions related to the entire try block.

 

 

Raising Exceptions

Note: Intentional error signaling, program flow control.

Sometimes, you need to indicate that an error condition has occurred even if Python hasn't automatically raised an exception. This is where the raise keyword comes in. You can raise an exception to stop the normal flow of execution and signal that something unexpected or erroneous has happened. This is particularly useful when you're writing functions or classes that need to enforce certain conditions or validate input.

 

The raise keyword

Note: For custom error signaling, aborts execution.

The raise keyword is used to explicitly throw an exception. When raise is executed, the current statement is aborted, and the program jumps to the nearest except block that can handle the raised exception. If no such except block is found, the program terminates and prints a traceback.

Syntax: raise ExceptionType("Error message")

 

Code Examples:

Beginner Friendly (Raising ValueError for invalid input):

Python

 

# python raise keyword, ValueError, custom error message, input validation
# Example 1: Raising a ValueError for invalid age
def set_age(age):
    if not isinstance(age, (int, float)):
        raise TypeError("Age must be a number.")
    if age < 0:
        raise ValueError("Age cannot be negative.")
    print(f"Age set to: {age}")

set_age(25)
# set_age(-5) # Uncomment to see ValueError
# set_age("old") # Uncomment to see TypeError

Explanation: This function validates the input age. If age is negative or not a number, it explicitly raises a ValueError or TypeError respectively, providing a clear error message.

 

Intermediate (Raising NotImplementedError in abstract methods):

Python

 

# python NotImplementedError, abstract method, raise example, inheritance
# Example 2: Raising NotImplementedError in an abstract method
class Shape:
    def area(self):
        # This method should be implemented by subclasses
        raise NotImplementedError("Subclasses must implement the 'area' method.")

class Circle(Shape):
    def __init__(self, radius):
        self.radius = radius
    def area(self):
        return 3.14159 * self.radius ** 2

class Square(Shape):
    def __init__(self, side):
        self.side = side
    # Oops, forgot to implement area here for demonstration

circle = Circle(5)
print(f"Circle area: {circle.area()}")

square = Square(4)
try:
    print(f"Square area: {square.area()}") # This will raise NotImplementedError
except NotImplementedError as e:
    print(f"Caught expected error for Square: {e}")

Explanation: NotImplementedError is commonly used in base classes to indicate that a method is expected to be overridden by subclasses. If a subclass fails to implement it and calls the base class method, this error is raised.

 

Advanced (Re-raising an exception after partial handling):

Python

 

# python re-raise exception, error propagation, partial handling
# Example 3: Re-raising an exception
def process_transaction(amount):
    try:
        # Simulate a database operation
        if amount <= 0:
            raise ValueError("Transaction amount must be positive.")
        
        # Simulate a network call that might fail
        if amount > 1000:
            raise ConnectionError("Network error during large transaction.")
            
        print(f"Processing transaction for {amount}...")
        # Further processing...
        
    except ValueError as e:
        print(f"Client-side validation failed: {e}")
        # Log the error, but re-raise for higher-level handling
        raise # Re-raises the last exception that occurred
    except ConnectionError as e:
        print(f"Attempting retry for network error: {e}")
        # Here you might implement retry logic
        # For simplicity, let's just re-raise after logging
        raise
    except Exception as e:
        print(f"An unhandled error occurred in process_transaction: {e}")
        raise # Re-raise if it's truly unexpected for this level

try:
    process_transaction(500)
    process_transaction(-100) # This will cause re-raise
except ValueError as e:
    print(f"Caught re-raised ValueError at top level: {e}")

try:
    process_transaction(2000) # This will cause re-raise
except ConnectionError as e:
    print(f"Caught re-raised ConnectionError at top level: {e}")

Explanation: Sometimes you might want to perform some local cleanup or logging when an exception occurs, but then let the exception propagate up the call stack to be handled by a higher-level except block. You can achieve this by simply using raise without any arguments within an except block. This re-raises the exception that was just caught.

 

Advanced (Raising an exception "from" another exception):

Python

 

# python exception chaining, raise from, debugging errors, context preservation
# Example 4: Raising an exception from another exception (Exception Chaining)
def read_config(file_path):
    try:
        with open(file_path, 'r') as f:
            data = f.read()
            config_dict = eval(data) # Dangerous! For demo, don't use eval in real code.
            return config_dict
    except FileNotFoundError as exc:
        # Raise a custom error with the original exception as context
        raise RuntimeError(f"Configuration file '{file_path}' not found or unreadable.") from exc
    except SyntaxError as exc:
        raise ValueError(f"Invalid syntax in configuration file '{file_path}'.") from exc
    except Exception as exc:
        raise Exception(f"Failed to load configuration from '{file_path}'.") from exc

try:
    read_config("non_existent_config.txt")
except RuntimeError as e:
    print(f"Caught: {e}")
    # The 'original exception was caused by...' message will be printed in the traceback
    # because of 'from exc'
except ValueError as e:
    print(f"Caught: {e}")

Explanation: The raise ... from ... syntax allows you to explicitly chain exceptions. This is incredibly useful for debugging, as it indicates that one exception was caused by another, preserving the original traceback and providing clearer context for the problem. Python also implicitly chains exceptions when an except block raises a new exception, but from makes it explicit.

 

Advanced (Raising custom exceptions based on complex conditions):

Python

 

# python custom exceptions, complex validation, raise multiple
# Example 5: Raising custom exceptions based on complex conditions
class ProductError(Exception):
    """Base exception for product-related errors."""
    pass

class InvalidPriceError(ProductError):
    """Raised when a product price is invalid."""
    def __init__(self, price):
        self.price = price
        super().__init__(f"Invalid price: {price}. Price must be positive.")

class ProductNotFoundError(ProductError):
    """Raised when a product is not found in the inventory."""
    def __init__(self, product_id):
        self.product_id = product_id
        super().__init__(f"Product with ID '{product_id}' not found.")

def get_product_details(product_id, inventory, min_price=0.01):
    product = inventory.get(product_id)
    if product is None:
        raise ProductNotFoundError(product_id)

    price = product.get("price")
    if price is None or not isinstance(price, (int, float)) or price < min_price:
        raise InvalidPriceError(price)

    print(f"Product '{product_id}': {product}")
    return product

inventory_data = {
    "P101": {"name": "Laptop", "price": 1200.00},
    "P102": {"name": "Mouse", "price": 25.50},
    "P103": {"name": "Keyboard", "price": -10.00}, # Invalid price
    "P104": {"name": "Monitor"} # Missing price
}

try:
    get_product_details("P101", inventory_data)
    get_product_details("P105", inventory_data) # Not found
except ProductError as e:
    print(f"Product related error: {e}")

try:
    get_product_details("P103", inventory_data) # Invalid price
except ProductError as e:
    print(f"Product related error: {e}")

try:
    get_product_details("P104", inventory_data) # Missing price
except ProductError as e:
    print(f"Product related error: {e}")

Explanation: This example showcases raising custom exceptions based on a hierarchy. We define ProductError as a base, and InvalidPriceError and ProductNotFoundError inherit from it. The get_product_details function uses these custom exceptions to clearly signal specific business logic errors based on complex validation rules, making the error handling more semantic and easier to debug.

 

 

Custom Exceptions

Note: Domain-specific errors, improve code clarity.

While Python provides a rich set of built-in exceptions, there will often be cases where your application needs to signal errors that are specific to your domain or business logic. This is where custom exceptions come in handy. By defining your own exception classes, you can make your error handling more explicit, readable, and easier to manage. Custom exceptions should typically inherit from the base Exception class or one of its built-in subclasses (e.g., ValueError, IOError) if your custom exception represents a more specific type of that general error.

 

Key Benefits:

Clarity: Error messages are more specific to your application's context.

Granularity: You can catch and handle specific types of errors unique to your system.

Maintainability: Easier to understand the nature of an error by its type.

 

Code Examples:

Beginner Friendly (Basic Custom Exception):

Python

 

# python custom exception, basic example, define exception
# Example 1: Basic custom exception for invalid temperature
class InvalidTemperatureError(Exception):
    """Raised when an invalid temperature value is encountered."""
    pass

def set_temperature(temp):
    if not isinstance(temp, (int, float)):
        raise TypeError("Temperature must be a number.")
    if temp < -273.15: # Absolute zero
        raise InvalidTemperatureError("Temperature cannot be below absolute zero.")
    print(f"Temperature set to: {temp}°C")

set_temperature(25)
# set_temperature(-300) # Uncomment to see custom exception

Explanation: We define InvalidTemperatureError by inheriting from Exception. When set_temperature detects a temperature below absolute zero, it raises this custom exception, providing a more domain-specific error than a generic ValueError.

 

Intermediate (Custom Exception with Data Attributes):

Python

 

# python custom exception with data, error details, object attributes
# Example 2: Custom exception with attributes to carry error details
class InsufficientFundsError(Exception):
    """Raised when an account does not have enough funds for a transaction."""
    def __init__(self, required_amount, current_balance):
        self.required_amount = required_amount
        self.current_balance = current_balance
        message = (f"Insufficient funds. Required: ${required_amount:.2f}, "
                   f"Available: ${current_balance:.2f}.")
        super().__init__(message)

def make_withdrawal(account_balance, amount):
    if amount <= 0:
        raise ValueError("Withdrawal amount must be positive.")
    if amount > account_balance:
        raise InsufficientFundsError(amount, account_balance)
    return account_balance - amount

balance = 500.00
try:
    new_balance = make_withdrawal(balance, 700.00)
    print(f"New balance: ${new_balance:.2f}")
except InsufficientFundsError as e:
    print(f"Withdrawal failed: {e}")
    print(f"  Required: ${e.required_amount:.2f}")
    print(f"  Available: ${e.current_balance:.2f}")

Explanation: InsufficientFundsError is designed to carry specific data (required_amount, current_balance) relevant to the error. This allows the except block to not just print a message, but also access the underlying problematic values for more detailed logging or user feedback.

 

Advanced (Custom Exception Hierarchy):

Python

 

# python custom exception hierarchy, exception inheritance, modular errors
# Example 3: Creating a custom exception hierarchy
class MyAppError(Exception):
    """Base exception for all errors in My Application."""
    pass

class ConfigurationError(MyAppError):
    """Base exception for configuration-related errors."""
    pass

class MissingSettingError(ConfigurationError):
    """Raised when a required setting is missing."""
    def __init__(self, setting_name):
        self.setting_name = setting_name
        super().__init__(f"Required configuration setting '{setting_name}' is missing.")

class InvalidValueError(ConfigurationError):
    """Raised when a configuration setting has an invalid value."""
    def __init__(self, setting_name, invalid_value, expected_type=None):
        self.setting_name = setting_name
        self.invalid_value = invalid_value
        message = f"Invalid value for setting '{setting_name}': '{invalid_value}'."
        if expected_type:
            message += f" Expected type: {expected_type.__name__}."
        super().__init__(message)

def load_app_config(config_data):
    if "api_key" not in config_data:
        raise MissingSettingError("api_key")
    
    timeout = config_data.get("timeout")
    if not isinstance(timeout, (int, float)) or timeout <= 0:
        raise InvalidValueError("timeout", timeout, int)
        
    print("Configuration loaded successfully.")
    return config_data

# Test cases
try:
    load_app_config({"timeout": 10}) # Missing API key
except MyAppError as e:
    print(f"App Error: {e}")

try:
    load_app_config({"api_key": "abc", "timeout": "ten"}) # Invalid timeout type
except MyAppError as e:
    print(f"App Error: {e}")

try:
    load_app_config({"api_key": "xyz", "timeout": 5}) # Valid
except MyAppError as e:
    print(f"App Error: {e}")

Explanation: This creates a hierarchy of custom exceptions, inheriting from a common base MyAppError. This allows for flexible error handling: you can catch MyAppError to handle any application-specific error, or catch ConfigurationError to handle any configuration issue, or catch MissingSettingError for a very specific problem.

 

Advanced (Custom Exception for API Rate Limiting):

Python

 

# python API error handling, custom exception, rate limiting, retry-after
# Example 4: Custom exception for API rate limiting
import time

class RateLimitExceededError(Exception):
    """
    Raised when an API rate limit has been exceeded.
    Includes 'retry_after_seconds' to inform the user how long to wait.
    """
    def __init__(self, retry_after_seconds):
        self.retry_after_seconds = retry_after_seconds
        super().__init__(f"API rate limit exceeded. Please retry after {retry_after_seconds} seconds.")

API_CALL_COUNT = 0
MAX_API_CALLS_PER_MINUTE = 5
LAST_RESET_TIME = time.time()

def make_api_call(data):
    global API_CALL_COUNT, LAST_RESET_TIME

    # Simulate rate limit reset every minute
    if time.time() - LAST_RESET_TIME > 60:
        API_CALL_COUNT = 0
        LAST_RESET_TIME = time.time()

    if API_CALL_COUNT >= MAX_API_CALLS_PER_MINUTE:
        remaining_time = 60 - (time.time() - LAST_RESET_TIME)
        raise RateLimitExceededError(max(0, int(remaining_time)))

    API_CALL_COUNT += 1
    print(f"API call successful for data: {data} (Calls left: {MAX_API_CALLS_PER_MINUTE - API_CALL_COUNT})")
    # Simulate actual API work
    time.sleep(0.1)
    return {"status": "success", "data": f"Processed {data}"}

# Simulate multiple API calls
for i in range(10):
    try:
        make_api_call(f"request_{i+1}")
    except RateLimitExceededError as e:
        print(f"Caught API error: {e}")
        print(f"Waiting for {e.retry_after_seconds} seconds before retrying...")
        time.sleep(e.retry_after_seconds + 1) # Add a buffer
        print("Retrying API call...")
    except Exception as e:
        print(f"An unexpected error during API call: {e}")

Explanation: This example defines a RateLimitExceededError that includes retry_after_seconds as an attribute. This allows the calling code to not just know that a rate limit was hit, but also how long to wait before retrying, enabling smarter, more resilient API integration.

 

Advanced (Custom Exception for Data Validation within a data structure):

Python

 

# python data validation, custom exception, list of objects, complex validation
# Example 5: Custom exception for data validation in a collection
class DataValidationError(Exception):
    """Base exception for data validation errors."""
    pass

class InvalidRecordError(DataValidationError):
    """Raised when a specific record in a dataset is invalid."""
    def __init__(self, record_index, field_name, value, expected_format):
        self.record_index = record_index
        self.field_name = field_name
        self.value = value
        self.expected_format = expected_format
        message = (f"Invalid record at index {record_index}: Field '{field_name}' "
                   f"has value '{value}' which does not match expected format: {expected_format}.")
        super().__init__(message)

def validate_dataset(dataset):
    validated_records = []
    for i, record in enumerate(dataset):
        try:
            if not isinstance(record, dict):
                raise InvalidRecordError(i, "record_type", type(record).__name__, "dictionary")
            
            if "id" not in record or not isinstance(record["id"], int) or record["id"] <= 0:
                raise InvalidRecordError(i, "id", record.get("id"), "positive integer")
            
            if "name" not in record or not isinstance(record["name"], str) or not record["name"].strip():
                raise InvalidRecordError(i, "name", record.get("name"), "non-empty string")
                
            validated_records.append(record)
        except InvalidRecordError as e:
            print(f"Skipping invalid record: {e}")
            # In a real scenario, you might log this or collect all errors.
        except Exception as e:
            print(f"An unexpected error occurred processing record {i}: {e}")
            
    return validated_records

sample_data = [
    {"id": 1, "name": "Alice"},
    {"id": 2, "name": "Bob", "email": "bob@example.com"},
    {"id": 3, "name": ""}, # Invalid name
    {"id": -4, "name": "Charlie"}, # Invalid id
    "not_a_dict", # Invalid record type
    {"name": "David"}, # Missing id
    {"id": 5, "name": "Eve"}
]

processed_data = validate_dataset(sample_data)
print("\nValidated Records:")
for record in processed_data:
    print(record)

Explanation: This example builds a custom InvalidRecordError that captures the record_index, field_name, value, and expected_format of the problematic data. This provides highly specific feedback when validating complex datasets, allowing for precise identification of validation failures within large inputs.

 

 

Common Built-in Exceptions

Note: Familiarize yourself with these, they are frequently encountered.

Python comes with a rich hierarchy of built-in exceptions that cover a wide range of common errors. Understanding these exceptions is crucial for writing effective except blocks and for debugging your code. All built-in exceptions derive from the BaseException class. Most user-defined exceptions should derive from Exception.

 

Here are some of the most common and important built-in exceptions you'll encounter:

ArithmeticError: Base class for errors that occur during arithmetic calculations.

ZeroDivisionError: Raised when division or modulo by zero occurs.

OverflowError: Raised when the result of an arithmetic operation is too large to be represented.

AttributeError: Raised when an attribute reference or assignment fails (e.g., trying to access a non-existent method or property on an object).

EOFError: Raised when the input() function hits an end-of-file condition (EOF) without reading any data.

ImportError: Raised when1 an import statement fails to find the module or when a from ... import fails to find a name within a module.

ModuleNotFoundError: A subclass of ImportError, specifically raised when a module could not be found.

IndexError: Raised when a sequence subscript (index) is out of range.

KeyError: Raised when a dictionary (or set) key is not found.

NameError: Raised when a local or global name is not found (i.e., a variable or function that hasn't been defined).

FileNotFoundError: Raised when a file or directory is requested but doesn't exist.

IOError (or OSError and its subclasses like PermissionError): Base class for I/O related errors. FileNotFoundError is a subclass of OSError.

SyntaxError: Raised when the parser encounters a syntax error.

IndentationError: A subclass of SyntaxError, specifically raised when there is incorrect indentation.

TypeError: Raised when an operation or function is applied to an object of inappropriate type (e.g., adding a string to an integer).

ValueError: Raised when a function receives an argument of the correct type but an inappropriate value (e.g., int("abc")).

RuntimeError: Raised when an error does not fall into any other category.

StopIteration: Raised by built-in next() function and an iterator's __next__() method to signal that there are no further items produced by the iterator.

MemoryError: Raised when an operation runs out of memory.

 

Code Examples:

Beginner Friendly (NameError and TypeError):

Python

 

# python common exceptions, NameError, TypeError, beginner debugging
# Example 1: NameError and TypeError
try:
    print(my_variable) # NameError: my_variable is not defined
except NameError as e:
    print(f"Caught NameError: {e}")

try:
    result = "hello" + 5 # TypeError: cannot concatenate str and int
except TypeError as e:
    print(f"Caught TypeError: {e}")

Explanation: This example demonstrates NameError when trying to use an undefined variable and TypeError when attempting an invalid operation between incompatible types.

 

Intermediate (IndexError and KeyError):

Python

 

# python list errors, dictionary errors, IndexError, KeyError
# Example 2: IndexError and KeyError
my_list = [10, 20, 30]
try:
    print(my_list[5]) # IndexError: list index out of range
except IndexError as e:
    print(f"Caught IndexError: {e}")

my_dict = {"name": "Python", "version": 3.9}
try:
    print(my_dict["language"]) # KeyError: 'language'
except KeyError as e:
    print(f"Caught KeyError: {e}")

Explanation: This covers IndexError when accessing a list with an out-of-bounds index and KeyError when trying to access a non-existent key in a dictionary.

 

Advanced (FileNotFoundError and ValueError with int()):

Python

 

# python file not found, ValueError, error handling best practices
# Example 3: FileNotFoundError and ValueError
def read_number_from_file(filepath):
    try:
        with open(filepath, 'r') as f:
            content = f.read()
            number = int(content.strip())
            return number
    except FileNotFoundError:
        print(f"Error: File '{filepath}' not found.")
        return None
    except ValueError:
        print(f"Error: Content of '{filepath}' is not a valid integer.")
        return None
    except Exception as e:
        print(f"An unexpected error occurred: {e}")
        return None

# Create a dummy file for testing
with open("valid_number.txt", "w") as f:
    f.write("123")
with open("invalid_number.txt", "w") as f:
    f.write("hello")

print(read_number_from_file("non_existent.txt"))
print(read_number_from_file("valid_number.txt"))
print(read_number_from_file("invalid_number.txt"))

Explanation: This function attempts to read an integer from a file. It handles FileNotFoundError if the file doesn't exist and ValueError if the file's content cannot be converted to an integer.

 

Advanced (ImportError/ModuleNotFoundError and AttributeError):

Python

 

# python import error, attribute error, common pitfalls, debugging
# Example 4: ImportError/ModuleNotFoundError and AttributeError
try:
    import non_existent_module # ImportError or ModuleNotFoundError
except ImportError as e:
    print(f"Caught ImportError: {e}")

class MyObject:
    def __init__(self):
        self.value = 10

obj = MyObject()
try:
    print(obj.non_existent_attribute) # AttributeError
except AttributeError as e:
    print(f"Caught AttributeError: {e}")

Explanation: This example demonstrates ImportError (or its subclass ModuleNotFoundError in newer Python versions) when trying to import a module that doesn't exist, and AttributeError when attempting to access a non-existent attribute on an object.

 

Advanced (MemoryError and RecursionError):

Python

 

# python advanced exceptions, MemoryError, RecursionError, system limits
# Example 5: MemoryError and RecursionError (caution: might crash small systems)
# Note: These can be difficult to reliably reproduce or dangerous on limited systems.

def create_large_list():
    """Attempts to create an extremely large list, potentially causing MemoryError."""
    try:
        # Try to allocate a list of 1 billion integers
        # This might actually exhaust memory on many systems.
        # list_data = [0] * (10**9)
        # print("Large list created (might not reach here on small memory systems).")
        print("Skipping very large list creation to avoid system freeze.")
    except MemoryError as e:
        print(f"Caught MemoryError: {e}")
    except Exception as e: # Catch any other errors if allocation fails differently
        print(f"An unexpected error during large list creation: {e}")

def infinite_recursion(count=0):
    """A function that calls itself indefinitely, causing RecursionError."""
    try:
        # print(f"Recursion depth: {count}")
        infinite_recursion(count + 1)
    except RecursionError as e:
        print(f"Caught RecursionError: {e}")
    except Exception as e:
        print(f"An unexpected error during recursion: {e}")

# Uncomment these calls to see the errors, but be aware of system resource usage.
# create_large_list()
# infinite_recursion()
print("Demonstration of MemoryError and RecursionError skipped by default for safety.")

Explanation: This example introduces MemoryError, which occurs when an operation runs out of available memory, and RecursionError, which arises when a function calls itself too many times, exceeding Python's recursion depth limit. These are typically harder to handle gracefully and often indicate a fundamental design flaw or resource constraint. They are generally only caught for logging or clean shutdown.

 

This concludes our comprehensive guide to Error Handling and Exceptions in Python. Mastering these concepts will significantly improve the robustness and reliability of your Python applications. Keep practicing, and happy coding!!!