PYnative

Python Programming

  • Learn Python
    • Python Tutorials
    • Python Basics
    • Python Interview Q&As
  • Exercises
    • Python Exercises
    • C Programming Exercises
    • C++ Exercises
  • Quizzes
  • Code Editor
    • Online Python Code Editor
    • Online C Compiler
    • Online C++ Compiler
Home » Python Exercises » Python Exception Handling Exercises: 20 Coding Problems with Solutions

Python Exception Handling Exercises: 20 Coding Problems with Solutions

Updated on: June 4, 2026 | Leave a Comment

This article contains 20 Python exception handling exercises, from beginner to expert level, covering topics from try/except/finally, custom exceptions, raising errors to multiple exception handling, raising exceptions and assertion statements to helping you write more efficient and Pythonic code.

Each coding challenge includes a Practice Problem, Hint, Solution code, and detailed Explanation, ensuring you don’t just copy code, but genuinely practice and understand how and why it works.

  • All solutions have been fully tested on Python 3.
  • Use our Online Code Editor to solve these exercises in real time.
  • Read Python exception handling for any help
+ Table of Contents (20 Exercises)

Table of contents

  • Exercise 1: Basic Try-Except
  • Exercise 2: Division Safety
  • Exercise 3: File Not Found
  • Exercise 4: Index Out of Range
  • Exercise 5: Key Error Handling
  • Exercise 6: Type Error Guard
  • Exercise 7: Multiple Exceptions
  • Exercise 8: Finally Block
  • Exercise 9: Else Clause
  • Exercise 10: Nested Try-Except
  • Exercise 11: Re-raising Exceptions
  • Exercise 12: Custom Exception Class
  • Exercise 13: Exception Chaining
  • Exercise 14: Validate User Input Loop
  • Exercise 15: Context Manager with Exceptions
  • Exercise 16: Exception Hierarchy
  • Exercise 17: Logging Exceptions
  • Exercise 18: Retry Decorator
  • Exercise 19: Thread-Safe Exception Handling
  • Exercise 20: Exception in Generator

Exercise 1: Basic Try-Except

Problem Statement: Write a Python program that asks the user to enter a number. If the input is not a valid integer, raise a ValueError and display a helpful error message instead of crashing.

Purpose: This exercise introduces the fundamental try/except pattern in Python. Handling invalid user input gracefully is one of the most common real-world uses of exception handling, and mastering it is a key step toward writing robust, user-friendly programs.

Given Input: User enters "hello" when prompted for a number.

Expected Output: Error: That was not a valid integer. Please enter a number.

▼ Hint
  • Use input() to read user input, then wrap the conversion attempt in a try block.
  • Use int() to convert the string to an integer. This raises ValueError automatically if the string is not numeric.
  • Catch the exception with except ValueError as e: and print your custom message inside that block.
▼ Solution & Explanation
try:
    user_input = input("Enter a number: ")
    number = int(user_input)
    print("You entered:", number)
except ValueError:
    print("Error: That was not a valid integer. Please enter a number.")Code language: Python (python)

Explanation:

  • try:: Opens the guarded block. Python attempts to run every statement inside it. If any line raises an exception, execution jumps immediately to the matching except block.
  • int(user_input): Converts the string to an integer. When the string contains non-numeric characters, Python raises a ValueError automatically, so you do not need to raise it manually here.
  • except ValueError:: Catches only ValueError exceptions. Using a specific exception type instead of a bare except is best practice because it avoids silently swallowing unrelated errors.
  • Print in the except block: Any code inside the except block runs only when the named exception is raised. Code after the entire try/except structure continues normally either way.

Exercise 2: Division Safety

Problem Statement: Create a function safe_divide(a, b) that divides a by b. If b is zero, handle the ZeroDivisionError and return None instead of letting the program crash.

Purpose: Division by zero is a classic runtime error that appears in financial calculations, data pipelines, and scientific code. This exercise teaches you to wrap risky arithmetic in exception handling and return a sentinel value when a valid result cannot be produced.

Given Input: Call safe_divide(10, 0) and safe_divide(10, 2).

Expected Output: None for the first call and 5.0 for the second.

▼ Hint
  • Place the division a / b inside a try block inside the function.
  • Catch ZeroDivisionError in the except clause and use return None there.
  • If no exception occurs, return the result of the division normally.
▼ Solution & Explanation
def safe_divide(a, b):
    try:
        result = a / b
        return result
    except ZeroDivisionError:
        print("Warning: Division by zero is not allowed.")
        return None

print(safe_divide(10, 2))   # 5.0
print(safe_divide(10, 0))   # NoneCode language: Python (python)

Explanation:

  • result = a / b: Performs the division. When b is zero, Python raises ZeroDivisionError before assigning anything to result.
  • except ZeroDivisionError:: Intercepts the error only when b is zero. All other exceptions (e.g., TypeError if non-numbers are passed) propagate normally, which is the correct behaviour.
  • return None: Returns Python’s built-in null value as a safe sentinel. The caller can check if result is None: to detect the failure without catching an exception itself.
  • Normal path: When b is non-zero, the except block is skipped entirely and return result executes as usual.

Exercise 3: File Not Found

Problem Statement: Write a program that tries to open a file that does not exist on disk. Catch the FileNotFoundError exception and display a clear, user-friendly message instead of showing a raw Python traceback.

Purpose: File I/O is one of the most error-prone operations in any program. Learning to handle missing files gracefully makes your scripts production-ready and prevents cryptic crashes when the environment differs from what you expect.

Given Input: Attempt to open "missing_file.txt" for reading.

Expected Output: Error: The file 'missing_file.txt' was not found. Please check the filename and path.

▼ Hint
  • Use open("missing_file.txt", "r") inside a try block.
  • Catch FileNotFoundError and print a descriptive message that includes the filename so the user knows exactly what went wrong.
  • You can embed the filename directly in the message string, or access it from the exception object using except FileNotFoundError as e: and then e.filename.
▼ Solution & Explanation
filename = "missing_file.txt"

try:
    with open(filename, "r") as f:
        content = f.read()
        print(content)
except FileNotFoundError:
    print(f"Error: The file '{filename}' was not found. Please check the filename and path.")Code language: Python (python)

Explanation:

  • with open(filename, "r") as f:: The with statement is the recommended way to open files because it automatically closes the file handle even if an exception occurs. The "r" flag opens the file in read-only mode.
  • FileNotFoundError: A subclass of OSError raised specifically when the path does not point to an existing file. Catching this precise type avoids masking other I/O errors such as permission denied.
  • f-string in the message: Embedding {filename} in the error message gives the user actionable information without requiring them to search the traceback.
  • Alternative: You can also use except FileNotFoundError as e: print(e.filename) to extract the filename directly from the exception object if you prefer not to store it in a variable beforehand.

Exercise 4: Index Out of Range

Problem Statement: Given a fixed list, ask the user to enter an index and print the element at that position. If the index is outside the valid range, catch the IndexError and display an informative message.

Purpose: Accessing list elements by index is something Python developers do constantly. This exercise builds the habit of validating or protecting index-based access, which is critical in data processing, game development, and any code that works with sequences of unknown length.

Given Input: items = ["apple", "banana", "cherry"] and the user enters index 5.

Expected Output: Error: Index 5 is out of range. The list has 3 items (valid indices: 0 to 2).

▼ Hint
  • First convert the user input to an integer with int(). You may want a nested try/except or a single block that catches both ValueError and IndexError.
  • Access the list with items[index] inside the try block. Python raises IndexError automatically when the index is too large or too small.
  • Use len(items) in the error message to tell the user the valid range dynamically.
▼ Solution & Explanation
items = ["apple", "banana", "cherry"]

try:
    index = int(input("Enter an index: "))
    print("Item:", items[index])
except ValueError:
    print("Error: Please enter a valid integer index.")
except IndexError:
    print(f"Error: Index {index} is out of range. "
          f"The list has {len(items)} items (valid indices: 0 to {len(items) - 1}).")Code language: Python (python)

Explanation:

  • int(input(...)): Combines input reading and conversion in one step. If the user types a non-integer string, ValueError is raised before the list access is ever attempted.
  • items[index]: Triggers IndexError when index is greater than or equal to len(items), or less than -len(items). Python supports negative indices (e.g., -1 for the last element), so values like -10 on a three-item list also cause this error.
  • Multiple except clauses: You can stack as many except blocks as needed after a single try. Python checks them in order and runs only the first matching one.
  • len(items) - 1: Computes the last valid index dynamically so the error message stays accurate even if the list changes size later.

Exercise 5: Key Error Handling

Problem Statement: Given a dictionary of country capitals, ask the user to enter a country name and print the corresponding capital. If the country is not in the dictionary, catch the KeyError and display a helpful message.

Purpose: Dictionary lookups are everywhere in Python: configuration parsing, JSON processing, API responses, and more. Knowing how to handle a missing key cleanly, rather than letting a KeyError crash your program, is an essential skill for working with real-world data.

Given Input: capitals = {"France": "Paris", "Japan": "Tokyo", "Brazil": "Brasilia"} and the user enters "Germany".

Expected Output: Error: 'Germany' was not found in the dictionary.

▼ Hint
  • Access the dictionary with capitals[country] inside a try block. Python raises KeyError automatically when the key is absent.
  • Catch KeyError as e and print the missing key. The exception object e holds the key as its first argument, so str(e) or e.args[0] gives you the key name as a string.
  • As an alternative approach, you could use dict.get(key) which returns None instead of raising an exception. Consider which style is cleaner for your use case.
▼ Solution & Explanation
capitals = {
    "France": "Paris",
    "Japan": "Tokyo",
    "Brazil": "Brasilia"
}

try:
    country = input("Enter a country name: ")
    capital = capitals[country]
    print(f"The capital of {country} is {capital}.")
except KeyError:
    print(f"Error: '{country}' was not found in the dictionary.")Code language: Python (python)

Explanation:

  • capitals[country]: Direct bracket access raises KeyError immediately when the key does not exist. This is the idiomatic way to look up a dictionary value when you expect the key to be present and want to treat its absence as an error.
  • except KeyError:: Catches the missing-key error and lets you respond with a custom message. The exception object contains the missing key value, so you can reference it in the message for clarity.
  • Alternative – dict.get(): Using capitals.get(country) returns None silently when the key is missing, avoiding the need for a try/except block entirely. Choose get() when absence is a normal, expected condition, and use bracket access with exception handling when absence should be treated as an error worth reporting.
  • Case sensitivity: Dictionary keys are case-sensitive in Python. Entering "france" instead of "France" would also trigger a KeyError. You could normalise input with country.title() or country.capitalize() to improve robustness.

Exercise 6: Type Error Guard

Problem Statement: Write a function add_values(a, b) that returns the sum of two values. If the arguments are of incompatible types (for example, a string and an integer), catch the TypeError and return a descriptive error message instead of crashing.

Purpose: Python is dynamically typed, which means type mismatches only surface at runtime. This exercise teaches you to guard operations that assume specific types, a habit that becomes critical when processing data from external sources such as APIs, CSV files, or user forms where the shape of the data is not always guaranteed.

Given Input: Call add_values(10, "20") and add_values(10, 20).

Expected Output: Error: incompatible types for addition. for the first call and 30 for the second.

▼ Hint
  • Place return a + b inside a try block. Python raises TypeError automatically when + is applied to incompatible types such as int and str.
  • Catch TypeError in the except clause and return a string message from there.
  • You can also use except TypeError as e: and include str(e) in the returned message for the exact Python error text.
▼ Solution & Explanation
def add_values(a, b):
    try:
        return a + b
    except TypeError:
        return "Error: incompatible types for addition."

print(add_values(10, 20))       # 30
print(add_values(10, "20"))     # Error: incompatible types for addition.Code language: Python (python)

Explanation:

  • return a + b: The + operator works for numbers, strings, and lists, but mixing types that do not support addition together (such as int and str) causes Python to raise TypeError immediately.
  • except TypeError:: Catches the type mismatch error specifically. Keeping the exception type narrow means other unexpected errors, such as a MemoryError, will still propagate and not be silently swallowed.
  • return from the except block: Returning a value from inside an except clause is perfectly valid. The caller receives either the computed result or the error string and can check which one was returned.
  • Note on "20" vs 20: add_values(10, "20") fails because Python will not silently coerce a string to an integer. If you want to support that, you would explicitly convert with int(b) or float(b) before adding, and handle the resulting ValueError separately.

Exercise 7: Multiple Exceptions

Problem Statement: Write a function parse_and_divide(value, divisor) that converts value to a float and then divides it by divisor. Use a single try block that handles ValueError (bad conversion) and ZeroDivisionError (division by zero) with separate messages for each.

Purpose: Real programs rarely face only one kind of failure. This exercise shows you how to stack multiple except clauses after one try block so each error type gets its own tailored response, keeping error-handling logic clear and maintainable.

Given Input: Call parse_and_divide("abc", 2) and parse_and_divide("10", 0) and parse_and_divide("10", 2).

Expected Output:

Error: 'abc' cannot be converted to a number.
Error: Cannot divide by zero.
5.0
▼ Hint
  • Put both float(value) and the division on separate lines inside the same try block. Python stops at the first line that raises an exception and jumps to the matching except.
  • Add two except clauses below: one for ValueError and one for ZeroDivisionError.
  • You can also catch multiple exceptions in a single clause using a tuple: except (ValueError, ZeroDivisionError) as e:, but here you want separate handling, so keep them apart.
▼ Solution & Explanation
def parse_and_divide(value, divisor):
    try:
        number = float(value)
        result = number / divisor
        return result
    except ValueError:
        return f"Error: '{value}' cannot be converted to a number."
    except ZeroDivisionError:
        return "Error: Cannot divide by zero."

print(parse_and_divide("abc", 2))   # Error: 'abc' cannot be converted to a number.
print(parse_and_divide("10", 0))    # Error: Cannot divide by zero.
print(parse_and_divide("10", 2))    # 5.0Code language: Python (python)

Explanation:

  • float(value): Attempts to convert the string to a floating-point number. When the string is not numeric (e.g., "abc"), Python raises ValueError and the division line is never reached.
  • number / divisor: Only executes if the conversion succeeded. If divisor is zero at this point, ZeroDivisionError is raised and the second except block takes over.
  • Order of except clauses: Python tests each clause from top to bottom and runs only the first match. For unrelated exception types, the order does not matter practically, but it is good practice to list more specific exceptions before broader ones.
  • Tuple shorthand: If you want identical handling for multiple types, you can write except (ValueError, ZeroDivisionError): in a single clause. Here they are kept separate because the error messages differ.

Exercise 8: Finally Block

Problem Statement: Write a function read_file(filename) that opens a file manually (without a with statement), reads its contents, and uses a finally block to ensure the file handle is always closed, whether the read succeeds or an exception is raised.

Purpose: The finally block is Python’s mechanism for guaranteed cleanup. Understanding it is essential for managing resources such as file handles, database connections, and network sockets correctly, especially in older codebases or situations where the with statement is not available.

Given Input: Call read_file("hello.txt") where the file exists, and then read_file("missing.txt") where it does not.

Expected Output:

File closed.
Hello from the file!
---
File closed.
Error: 'missing.txt' not found.
▼ Hint
  • Open the file with f = open(filename, "r") before the try block, or initialise f = None before and open inside the try so the finally block can safely check if f: before calling f.close().
  • Put f.read() inside the try block and catch FileNotFoundError in the except clause.
  • Place f.close() inside finally:. This block always runs, making it the right place for resource cleanup.
▼ Solution & Explanation
def read_file(filename):
    f = None
    try:
        f = open(filename, "r")
        content = f.read()
        return content
    except FileNotFoundError:
        return f"Error: '{filename}' not found."
    finally:
        if f:
            f.close()
        print("File closed.")

print(read_file("hello.txt"))
print("---")
print(read_file("missing.txt"))Code language: Python (python)

Explanation:

  • f = None: Initialising f before the try block ensures the variable exists in the finally scope. If open() itself raises an exception (e.g., FileNotFoundError), f would never be assigned, so checking if f: before calling f.close() prevents a secondary AttributeError.
  • finally:: This block runs unconditionally after the try and any matching except clause, whether the code succeeded, raised a caught exception, or raised an uncaught one. It is the correct place for cleanup logic.
  • f.close(): Releases the operating system file handle. Leaving files open wastes resources and can cause data corruption or lock issues on Windows systems.
  • Why not just use with?: The with open(...) as f: statement handles closing automatically and is the modern preferred approach. This exercise intentionally avoids it to demonstrate what with does under the hood and to prepare you for codebases that do not use it.

Exercise 9: Else Clause

Problem Statement: Write a function safe_sqrt(value) that converts user input to a float and calculates its square root. Use the try-except-else pattern so that a success message is printed only when no exception is raised, keeping success and error paths cleanly separated.

Purpose: The else clause on a try block is one of Python’s less-known but very useful features. It keeps the “happy path” code out of the try block itself, which means only the risky operation is guarded and success logic is not accidentally shielded from other exceptions it might raise.

Given Input: Call safe_sqrt("25") and safe_sqrt("abc").

Expected Output:

Success: the square root of 25.0 is 5.0
Error: 'abc' is not a valid number.
▼ Hint
  • Put only the risky conversion number = float(value) inside the try block.
  • Add an except ValueError: clause that prints the error message.
  • Add an else: block after the except. Code in else runs only when the try block completes without raising any exception. Place your square root calculation and success print statement there.
  • You can compute the square root with number ** 0.5 or by importing math.sqrt.
▼ Solution & Explanation
import math

def safe_sqrt(value):
    try:
        number = float(value)
    except ValueError:
        print(f"Error: '{value}' is not a valid number.")
    else:
        result = math.sqrt(number)
        print(f"Success: the square root of {number} is {result}")

safe_sqrt("25")    # Success: the square root of 25.0 is 5.0
safe_sqrt("abc")   # Error: 'abc' is not a valid number.Code language: Python (python)

Explanation:

  • try contains only the risky line: Only float(value) sits inside try. This is intentional. If math.sqrt() were also inside try and it raised an error (e.g., on a negative number), that error would be caught by the except ValueError clause, which would be misleading.
  • else:: Runs if and only if the try block completed without raising any exception. It is the correct place for code that depends on the success of the guarded operation but should not itself be protected by the same except clause.
  • math.sqrt(number): Returns the square root as a float. It raises ValueError for negative inputs, but since it lives in the else block, that error is not caught here and would propagate to the caller – which is the correct and transparent behaviour.
  • Clause order: The full order of clauses on a try statement is: try – except – else – finally. You can combine all four in a single structure when needed.

Exercise 10: Nested Try-Except

Problem Statement: Write a function load_and_parse(filename, key) that opens a JSON file (outer try) and then looks up a key in the parsed data (inner try). Handle FileNotFoundError at the outer level and KeyError at the inner level so each failure is reported with an appropriate message.

Purpose: Complex workflows often have multiple distinct failure points that require different recovery strategies. Nested try blocks let you apply fine-grained handling close to the source of each potential error, keeping the logic easier to reason about than a single flat block that catches everything.

Given Input: A file data.json containing {"name": "Alice", "age": 30}. Call load_and_parse("data.json", "name"), then load_and_parse("data.json", "email"), then load_and_parse("missing.json", "name").

Expected Output:

Alice
Error: key 'email' not found in the data.
Error: file 'missing.json' does not exist.
▼ Hint
  • In the outer try, open the file and parse it with json.load(f). Catch FileNotFoundError in the outer except.
  • Inside the outer try, after parsing succeeds, open an inner try block that does data[key]. Catch KeyError in the inner except.
  • Remember to import json at the top of your script.
▼ Solution & Explanation
import json

def load_and_parse(filename, key):
    try:
        with open(filename, "r") as f:
            data = json.load(f)
        try:
            value = data[key]
            print(value)
        except KeyError:
            print(f"Error: key '{key}' not found in the data.")
    except FileNotFoundError:
        print(f"Error: file '{filename}' does not exist.")

load_and_parse("data.json", "name")     # Alice
load_and_parse("data.json", "email")    # Error: key 'email' not found in the data.
load_and_parse("missing.json", "name")  # Error: file 'missing.json' does not exist.Code language: Python (python)

Explanation:

  • Outer try – file access: Wraps the file open and JSON parse operations. If either fails due to a missing file, the outer except FileNotFoundError catches it and the inner block never runs. This prevents a cascade of secondary errors when the prerequisite (the file) does not exist.
  • Inner try – key lookup: Only reached when the file loaded successfully. It isolates the dictionary access so that a missing key is handled specifically without interfering with the outer error handler.
  • json.load(f): Parses the entire file contents into a Python dictionary. It can raise json.JSONDecodeError if the file is not valid JSON. That exception is not caught here and would propagate up, which is intentional: the exercise focuses on nesting, not exhaustive handling.
  • When to nest vs. stack: Use nested try blocks when later operations depend on the success of earlier ones and each needs its own handler. Use stacked (sequential) try blocks when operations are independent. Deeply nested structures (three or more levels) are usually a sign to refactor into separate functions.

Exercise 11: Re-raising Exceptions

Problem Statement: Write a function process_data(value) that converts a string to an integer. Catch any ValueError, log a message to the console, and then re-raise the same exception so the calling code can handle it at a higher level.

Purpose: Catching an exception purely to re-raise it is a common pattern in layered applications. A lower-level function may need to record a log entry or clean up state without fully swallowing the error, while still allowing the caller to decide how to respond. This exercise shows exactly how to do that with a bare raise statement.

Given Input: Call process_data("abc") from a wrapping try block in the main code.

Expected Output:

[log] process_data failed: invalid literal for int() with base 10: 'abc'
[main] Caught re-raised exception: invalid literal for int() with base 10: 'abc'
▼ Hint
  • Inside the except ValueError as e: block, print your log message using str(e) to include the original error text.
  • After printing, use a bare raise statement (with no argument) to re-raise the exact same exception object. This preserves the original traceback, which is important for debugging.
  • In the calling code, wrap process_data() in its own try/except ValueError block to demonstrate that the re-raised exception can be caught there.
▼ Solution & Explanation
def process_data(value):
    try:
        return int(value)
    except ValueError as e:
        print(f"[log] process_data failed: {e}")
        raise

# Calling code
try:
    process_data("abc")
except ValueError as e:
    print(f"[main] Caught re-raised exception: {e}")Code language: Python (python)

Explanation:

  • except ValueError as e:: Binds the exception object to the name e, giving access to the error message via str(e) or directly inside an f-string.
  • Bare raise: Re-raises the current active exception without modifying it. This is different from raise e, which would create a new traceback starting at that line. A bare raise preserves the full original traceback, which is critical for diagnosing where the error actually originated.
  • Layered handling: After process_data logs and re-raises, execution resumes in the caller’s except block. The caller sees the same exception type and message, so it can respond independently – for example, by returning a default value, displaying a user-facing message, or logging again at a higher severity level.
  • When to use this pattern: Re-raising is appropriate when a function has side effects to perform on failure (logging, metrics, cleanup) but is not the right place to make the final recovery decision. Avoid it when you actually want to suppress the error entirely – use a plain except without raise for that.

Exercise 12: Custom Exception Class

Problem Statement: Define a custom exception class InsufficientFundsError. Then create a BankAccount class with a withdraw(amount) method that raises InsufficientFundsError when the withdrawal amount exceeds the current balance. Handle the exception in the calling code.

Purpose: Built-in exceptions are generic. Custom exceptions make your code self-documenting and allow callers to catch exactly the error your library or class can produce, without accidentally catching unrelated errors of the same built-in type. This pattern is standard in professional Python packages and frameworks.

Given Input: Create a BankAccount with a balance of 100. Try to withdraw 150.

Expected Output:

Error: Cannot withdraw 150. Available balance: 100.
▼ Hint
  • Define class InsufficientFundsError(Exception): – inheriting from Exception is the standard way to create a custom exception. You can leave the body as pass for a minimal version, or add an __init__ to store extra context.
  • In BankAccount.withdraw(), compare amount to self.balance and call raise InsufficientFundsError(...) with a descriptive message when the check fails.
  • In the calling code, use except InsufficientFundsError as e: to catch only your custom exception and print str(e).
▼ Solution & Explanation
class InsufficientFundsError(Exception):
    def __init__(self, amount, balance):
        self.amount = amount
        self.balance = balance
        super().__init__(
            f"Cannot withdraw {amount}. Available balance: {balance}."
        )

class BankAccount:
    def __init__(self, balance):
        self.balance = balance

    def withdraw(self, amount):
        if amount > self.balance:
            raise InsufficientFundsError(amount, self.balance)
        self.balance -= amount
        print(f"Withdrew {amount}. New balance: {self.balance}.")

account = BankAccount(100)

try:
    account.withdraw(150)
except InsufficientFundsError as e:
    print(f"Error: {e}")Code language: Python (python)

Explanation:

  • class InsufficientFundsError(Exception):: Inheriting from the built-in Exception class is the correct base for most custom exceptions. The custom __init__ stores the attempted amount and current balance as attributes, making them available for programmatic inspection in the caller, not just as a string message.
  • super().__init__(...): Passes the human-readable message up to the base Exception class so that str(e) and print(e) work as expected without any extra effort from the caller.
  • raise InsufficientFundsError(amount, self.balance): Raises the custom exception with contextual data baked in. The caller can catch it by name and access e.amount or e.balance for fine-grained error recovery, such as suggesting a valid withdrawal amount.
  • Why not raise ValueError?: You could raise ValueError here, but then callers cannot distinguish an insufficient-funds error from any other value error in the system. A custom exception type creates a precise, unambiguous signal that belongs to your domain.

Exercise 13: Exception Chaining

Problem Statement: Write a function load_config(filename) that reads a JSON config file. If the file is missing, catch the FileNotFoundError and raise a higher-level ConfigurationError using raise ConfigurationError(...) from original_exception to preserve the original cause in the chain.

Purpose: Exception chaining lets you translate low-level technical errors into meaningful domain-level errors without discarding the debugging context. A caller that receives a ConfigurationError knows exactly what went wrong in business terms, while the full original traceback is still accessible via __cause__ for developers who need it.

Given Input: Call load_config("config.json") when the file does not exist.

Expected Output:

ConfigurationError: Could not load config file 'config.json'.
Caused by: [Errno 2] No such file or directory: 'config.json'
▼ Hint
  • Define class ConfigurationError(Exception): pass as a minimal custom exception.
  • Inside the except FileNotFoundError as e: block, write raise ConfigurationError(f"Could not load config file '{filename}'.") from e. The from e part attaches the original exception as the __cause__ of the new one.
  • In the calling code, catch ConfigurationError as e and print both e and e.__cause__ to show both levels of the chain.
▼ Solution & Explanation
import json

class ConfigurationError(Exception):
    pass

def load_config(filename):
    try:
        with open(filename, "r") as f:
            return json.load(f)
    except FileNotFoundError as e:
        raise ConfigurationError(
            f"Could not load config file '{filename}'."
        ) from e

try:
    load_config("config.json")
except ConfigurationError as e:
    print(f"ConfigurationError: {e}")
    print(f"Caused by: {e.__cause__}")Code language: Python (python)

Explanation:

  • raise NewException(...) from e: The from keyword explicitly chains the new exception to the original one. Python sets new_exception.__cause__ = e and marks the chain as explicit. When the traceback is printed in full, Python shows both exceptions with the message “The above exception was the direct cause of the following exception.”
  • e.__cause__: The attribute holding the original exception. Accessing it programmatically lets you log or display the root cause without relying on the full traceback printout.
  • Implicit chaining vs. explicit chaining: If you raise a new exception inside an except block without from, Python still attaches the original exception as __context__ (implicit chaining). Using from e is the explicit form and signals intent clearly. Using from None suppresses the chain entirely when you do not want the original cause exposed.
  • Domain translation: FileNotFoundError is an OS-level detail. ConfigurationError is a business-level concept. Translating between them at the boundary of your module keeps the public API clean while still preserving the technical root cause for debugging.

Exercise 14: Validate User Input Loop

Problem Statement: Write a program that repeatedly prompts the user to enter a positive integer. If the input is not a valid integer or is not positive, catch the exception or condition, display an error message, and ask again. The loop should only exit when valid input is received.

Purpose: Real applications rarely get clean input on the first attempt. This exercise combines exception handling with a control loop to build a robust input routine – a pattern found in CLI tools, configuration wizards, and any program that interacts directly with a human user.

Given Input: The user types abc, then -5, then 7 across three prompts.

Expected Output:

Enter a positive integer: abc
Error: 'abc' is not a valid integer. Try again.
Enter a positive integer: -5
Error: -5 is not positive. Try again.
Enter a positive integer: 7
You entered: 7
▼ Hint
  • Use a while True: loop. Inside it, wrap int(input(...)) in a try block and catch ValueError to handle non-integer input.
  • After a successful conversion, check whether the number is positive using an if statement. If it is not, print an error and use continue to restart the loop iteration.
  • When both checks pass, print the success message and use break to exit the loop.
▼ Solution & Explanation
while True:
    try:
        raw = input("Enter a positive integer: ")
        number = int(raw)
    except ValueError:
        print(f"Error: '{raw}' is not a valid integer. Try again.")
        continue
    if number <= 0:
        print(f"Error: {number} is not positive. Try again.")
        continue
    print(f"You entered: {number}")
    breakCode language: Python (python)

Explanation:

  • while True:: Creates an infinite loop that runs until a break statement is reached. This is the standard Python idiom for “keep trying until success” input loops.
  • except ValueError: ... continue: When the conversion fails, the error is printed and continue sends execution back to the top of the loop immediately, skipping the positivity check and the break.
  • if number <= 0: ... continue: Handles the case where the input was a valid integer but fails the business rule. Using a plain if here (rather than raising an exception) is appropriate because this is an expected, normal condition rather than a programming error.
  • break: Exits the loop only after both the type check and the value check have passed. Placing break at the end of the loop body after all validations makes the success condition easy to identify at a glance.
  • Storing raw separately: Saving the raw input string in raw before converting allows the error message to echo back exactly what the user typed, which is more helpful than a generic “invalid input” message.

Exercise 15: Context Manager with Exceptions

Problem Statement: Implement a custom context manager class SuppressError using __enter__ and __exit__ that silently suppresses a specific exception type passed at construction time. Demonstrate it suppressing a ValueError while letting a TypeError propagate normally.

Purpose: Understanding what happens inside a with statement unlocks a powerful Python design pattern. Context managers are used everywhere – in file handling, database transactions, threading locks, and test frameworks. Knowing how __exit__ controls exception propagation lets you build your own resource-management and error-suppression utilities.

Given Input: Use SuppressError(ValueError) as a context manager around a block that raises ValueError, then demonstrate that a TypeError raised inside a second block is not suppressed.

Expected Output:

Entering context...
ValueError suppressed.
Outside context - execution continued.
Entering context...
TypeError: unsupported operand type(s) for +: 'int' and 'str'
▼ Hint
  • Define __enter__(self) to print the entry message and return self (or any value you want bound to the as variable).
  • __exit__(self, exc_type, exc_val, exc_tb) receives three arguments when an exception occurs: the exception class, the instance, and the traceback. If no exception occurred, all three are None.
  • Return True from __exit__ to suppress the exception, or False (or None) to let it propagate. Check exc_type against self.exception_type using issubclass to decide which to do.
▼ Solution & Explanation
class SuppressError:
    def __init__(self, exception_type):
        self.exception_type = exception_type

    def __enter__(self):
        print("Entering context...")
        return self

    def __exit__(self, exc_type, exc_val, exc_tb):
        if exc_type is not None and issubclass(exc_type, self.exception_type):
            print(f"{exc_type.__name__} suppressed.")
            return True   # suppress the exception
        return False      # re-raise anything else

# Suppressed case
with SuppressError(ValueError):
    raise ValueError("bad value")
print("Outside context - execution continued.")

# Non-suppressed case
with SuppressError(ValueError):
    result = 1 + "two"    # raises TypeErrorCode language: Python (python)

Explanation:

  • __enter__(self): Called at the start of the with block. Whatever it returns is bound to the variable after as. Here it returns self, but it could return any object – a file handle, a database cursor, or None.
  • __exit__(self, exc_type, exc_val, exc_tb): Called when the with block exits, whether normally or due to an exception. When no exception occurred, all three parameters are None. When an exception did occur, they hold the type, value, and traceback respectively.
  • issubclass(exc_type, self.exception_type): Using issubclass rather than exc_type == self.exception_type means the suppression also applies to subclasses of the target exception, which is consistent with how except clauses work.
  • Return value of __exit__: Returning True is the signal to Python that the exception has been handled and should be suppressed. Returning False or None lets the exception propagate as if the with block were not there. This is the single most important rule of context manager exception handling.

Exercise 16: Exception Hierarchy

Problem Statement: Define a three-level exception hierarchy: AppError as the base, DatabaseError inheriting from it, and ConnectionError inheriting from DatabaseError. Raise a ConnectionError and show that catching DatabaseError or AppError also intercepts it, while catching an unrelated exception type does not.

Purpose: Exception hierarchies are the backbone of professional Python library design. They let users of your code choose how broadly or narrowly to catch errors. A caller that wants to handle all database problems catches DatabaseError; one that wants only connectivity failures catches ConnectionError. This exercise makes that mechanism concrete.

Given Input: Raise ConnectionError("host unreachable") and catch it three times – once with each level of the hierarchy – to show that all three work.

Expected Output:

Caught as ConnectionError: host unreachable
Caught as DatabaseError: host unreachable
Caught as AppError: host unreachable
▼ Hint
  • Define each class with a single-line body of pass: class AppError(Exception): pass, then class DatabaseError(AppError): pass, then class ConnectionError(DatabaseError): pass.
  • Write three separate try/except blocks, each raising ConnectionError and catching a different level of the hierarchy, to demonstrate the relationship.
  • Note that Python has a built-in ConnectionError. Prefix your class names with your module or app name (e.g., AppConnectionError) in real projects to avoid shadowing built-ins.
▼ Solution & Explanation
class AppError(Exception):
    pass

class DatabaseError(AppError):
    pass

class AppConnectionError(DatabaseError):
    pass

# Catch at the most specific level
try:
    raise AppConnectionError("host unreachable")
except AppConnectionError as e:
    print(f"Caught as ConnectionError: {e}")

# Catch at the mid level
try:
    raise AppConnectionError("host unreachable")
except DatabaseError as e:
    print(f"Caught as DatabaseError: {e}")

# Catch at the base level
try:
    raise AppConnectionError("host unreachable")
except AppError as e:
    print(f"Caught as AppError: {e}")Code language: Python (python)

Explanation:

  • Inheritance chain: AppConnectionError is a DatabaseError, which is an AppError, which is an Exception. Python’s except clause uses isinstance semantics, so catching any ancestor in the chain will intercept the raised exception.
  • pass as body: For simple marker exceptions that carry no extra data beyond a message string, a body of pass is perfectly valid. The inherited __init__ from Exception already handles storing and displaying the message.
  • Catching broadly vs. narrowly: Catching AppError in a top-level handler is a common pattern for “catch all application errors and log them.” Lower layers catch specific subclasses to attempt targeted recovery. This mirrors how logging frameworks and web frameworks handle request-level errors.
  • Avoid shadowing built-ins: Python has a built-in ConnectionError (a subclass of OSError). Defining a class with the same name in module scope shadows it. Prefixing with your app or module name (as done here with AppConnectionError) prevents subtle bugs where code expecting the built-in gets yours instead.

Exercise 17: Logging Exceptions

Problem Statement: Configure Python’s logging module to write to a file called app.log. Write a function divide(a, b) that catches a ZeroDivisionError and uses logging.exception() to record the full traceback to the log file before returning None.

Purpose: Printing errors to the console is fine for learning, but production code needs persistent, structured logs. Python’s built-in logging module provides log levels, file rotation, formatters, and – crucially – the ability to capture full tracebacks automatically. This exercise builds the habit of logging exceptions properly from the start.

Given Input: Call divide(10, 0).

Expected Output:

Console: divide(10, 0) returned None
app.log: ERROR:root:Division failed - ZeroDivisionError with full traceback
▼ Hint
  • Call logging.basicConfig(filename="app.log", level=logging.ERROR) once at the top of your script to configure the file handler.
  • Inside the except block, use logging.exception("your message") rather than logging.error(). The difference is that logging.exception() automatically appends the full traceback to the log entry without any extra work.
  • Open app.log in a text editor after running the script to inspect the full traceback that was written.
▼ Solution & Explanation
import logging

logging.basicConfig(
    filename="app.log",
    level=logging.ERROR,
    format="%(asctime)s - %(levelname)s - %(message)s",
)

def divide(a, b):
    try:
        return a / b
    except ZeroDivisionError:
        logging.exception("Division failed: attempted to divide %s by zero.", a)
        return None

result = divide(10, 0)
print(f"divide(10, 0) returned {result}")

result = divide(10, 2)
print(f"divide(10, 2) returned {result}")Code language: Python (python)

Explanation:

  • logging.basicConfig(...): Sets up the root logger with a file handler, minimum log level, and a format string. The %(asctime)s token adds a timestamp to every entry, making it much easier to trace when errors occurred in a running system.
  • logging.exception(msg): Logs at ERROR level and automatically appends the current exception’s full traceback to the log entry. It must be called from inside an active except block so Python knows which exception to attach. Using this instead of logging.error(msg, exc_info=True) is simply more concise – they produce identical output.
  • Log levels: Python’s logging hierarchy from lowest to highest is: DEBUG, INFO, WARNING, ERROR, CRITICAL. Setting level=logging.ERROR means only ERROR and CRITICAL messages are written. Adjust the level to DEBUG during development to capture more detail.
  • Why log and return None?: The function handles the error internally (logging it) and returns a safe sentinel value. The caller does not need to know an exception occurred unless it checks the return value. This pattern suits library functions where the caller may not always want to write their own try/except.

Exercise 18: Retry Decorator

Problem Statement: Write a @retry(times=3) decorator factory that re-runs the decorated function up to times attempts if it raises an exception. If all attempts fail, re-raise the last exception. Demonstrate it on a function that fails twice and succeeds on the third call.

Purpose: Transient failures – network timeouts, temporary service unavailability, rate-limit responses – are a fact of life in distributed systems. A retry decorator is one of the most reused tools in a Python developer’s toolkit. Building it from scratch cements understanding of decorators, closures, and exception handling all at once.

Given Input: A function flaky_call() that fails on the first two calls and succeeds on the third.

Expected Output:

Attempt 1 failed: temporary failure
Attempt 2 failed: temporary failure
Attempt 3 succeeded.
Result: success
▼ Hint
  • retry must be a function that accepts times and returns a decorator (a function that accepts the target function). This two-level nesting is the standard pattern for decorators that take arguments.
  • Inside the innermost wrapper, use a for attempt in range(1, times + 1): loop. Wrap the function call in try/except Exception and store the exception. Break out of the loop on success.
  • After the loop, if the function never succeeded, re-raise the stored exception with a bare raise last_exception.
▼ Solution & Explanation
import functools
def retry(times=3):
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            last_exception = None
            for attempt in range(1, times + 1):
                try:
                    result = func(*args, **kwargs)
                    return result
                except Exception as e:
                    last_exception = e
                    print(f"Attempt {attempt} failed: {e}")
            raise last_exception
        return wrapper
    return decorator
# Simulated flaky function
call_count = 0
@retry(times=3)
def flaky_call():
    global call_count
    call_count += 1
    if call_count < 3:
        raise RuntimeError("temporary failure")
    print(f"Attempt {call_count} succeeded.")
    return "success"
print("Result:", flaky_call())Code language: Python (python)

Explanation:

  • Three-level nesting: retry(times) is the factory that captures the argument. decorator(func) is the actual decorator that wraps the target function. wrapper(*args, **kwargs) is the replacement function that runs at call time. Each level closes over the variables of the level above it.
  • @functools.wraps(func): Copies the original function’s __name__, __doc__, and other metadata onto wrapper. Without it, debugging tools and introspection would show wrapper instead of the real function name, making tracebacks harder to read.
  • last_exception = e: Stores the most recent exception so it can be re-raised after all attempts are exhausted. Without this, the variable would go out of scope when the loop ends and the exception would be lost.
  • Catching Exception broadly: The decorator catches all Exception subclasses by design, since it cannot know which specific errors the decorated function might raise. In production you would typically add an exceptions parameter so callers can restrict which error types trigger a retry – for example, @retry(times=3, on=requests.Timeout).

Exercise 19: Thread-Safe Exception Handling

Problem Statement: Launch five threads that each perform a division. Some will raise a ZeroDivisionError. Catch exceptions inside each thread and store them in a shared list protected by a threading.Lock. After all threads complete, iterate the list and report all failures.

Purpose: Exceptions raised in a thread do not propagate to the main thread automatically – they simply terminate the thread silently unless you handle them explicitly. This exercise teaches the correct pattern for capturing and surfacing thread-level errors in concurrent Python programs.

Given Input: Five threads dividing 10 by the values [2, 0, 5, 0, 1].

Expected Output:

Thread-1: 10 / 2 = 5.0
Thread-3: 10 / 5 = 2.0
Thread-5: 10 / 1 = 10.0

Errors collected:
Thread-2: division by zero
Thread-4: division by zero
▼ Hint
  • Create a errors = [] list and a lock = threading.Lock() before spawning threads. Both are shared across all thread functions via closure or by passing them as arguments.
  • Inside the thread function, wrap the division in try/except ZeroDivisionError. In the except block, use with lock: to acquire the lock before appending to errors.
  • After starting all threads, call t.join() on each to wait for completion before reading the errors list in the main thread.
▼ Solution & Explanation
import threading

errors = []
lock = threading.Lock()

def divide(thread_name, a, b):
    try:
        result = a / b
        print(f"{thread_name}: {a} / {b} = {result}")
    except ZeroDivisionError as e:
        with lock:
            errors.append((thread_name, e))

divisors = [2, 0, 5, 0, 1]
threads = []

for i, divisor in enumerate(divisors, start=1):
    t = threading.Thread(
        target=divide,
        args=(f"Thread-{i}", 10, divisor)
    )
    threads.append(t)
    t.start()

for t in threads:
    t.join()

if errors:
    print("\nErrors collected:")
    for name, err in errors:
        print(f"{name}: {err}")Code language: Python (python)

Explanation:

  • Exceptions do not cross thread boundaries: A ZeroDivisionError raised inside a threading.Thread target function terminates that thread silently. It never reaches the main thread’s exception handlers. The only way to surface it is to catch it inside the thread and communicate it through a shared data structure.
  • with lock: before appending: The errors list is a shared mutable object. Multiple threads could attempt to append at the same moment, and while CPython’s GIL often makes individual list appends safe in practice, using a Lock is the correct and portable approach that works regardless of the Python implementation.
  • t.join(): Blocks the main thread until the target thread finishes. Calling join() on every thread before reading errors guarantees that all threads have completed and all exceptions have been appended before the report is printed.
  • Alternative – concurrent.futures: The ThreadPoolExecutor from concurrent.futures handles exception collection automatically. Calling future.result() re-raises the exception in the main thread. For new code that does not need direct thread control, this is the preferred approach.

Exercise 20: Exception in Generator

Problem Statement: Write a generator function file_lines(filename) that yields lines from a file one at a time, handles IOError gracefully if the file cannot be opened, and supports receiving injected exceptions from outside using generator.throw(). Demonstrate injecting a RuntimeError mid-iteration to cancel reading early.

Purpose: Generators are lazy pipelines, and like any pipeline they need to handle both internal errors (bad file) and external signals (cancel this operation). The throw() method is the standard way to push an exception into a running generator, and understanding it is essential for building cooperative async-style systems and robust data pipelines.

Given Input: A file sample.txt with three lines. Yield the first line normally, then inject a RuntimeError before the second line is yielded.

Expected Output:

Line 1: Hello from line 1
Generator cancelled: operation aborted
Done.
▼ Hint
  • Open the file inside a try/except IOError block at the top of the generator. If the file cannot be opened, print an error and return (which stops the generator immediately).
  • Wrap the yield line statement in its own try/except RuntimeError. When generator.throw(RuntimeError, "message") is called, Python resumes the generator at the yield and immediately raises RuntimeError there, which your inner except can catch.
  • After catching the injected exception, use return to stop the generator cleanly, or re-raise if you want the caller to see it.
▼ Solution & Explanation
def file_lines(filename):
    try:
        f = open(filename, "r")
    except IOError as e:
        print(f"Could not open file: {e}")
        return

    with f:
        for line in f:
            try:
                yield line.rstrip("\n")
            except RuntimeError as e:
                print(f"Generator cancelled: {e}")
                return

# Create a sample file for the demo
with open("sample.txt", "w") as f:
    f.write("Hello from line 1\nHello from line 2\nHello from line 3\n")

gen = file_lines("sample.txt")

# Get the first line normally
line = next(gen)
print(f"Line 1: {line}")

# Inject an exception before the second line
try:
    gen.throw(RuntimeError, "operation aborted")
except StopIteration:
    pass

print("Done.")Code language: Python (python)

Explanation:

  • try/except IOError around open(): Guards the file open attempt at the very start of the generator. If it fails, the generator prints the error and exits via return, which raises StopIteration to the caller – the same signal as a generator that ran to completion.
  • yield line.rstrip("\n") inside try: The key insight is that yield is a suspension point. When the generator is paused at yield, calling gen.throw(ExcType, msg) resumes it and immediately raises the given exception at that exact line. The surrounding try/except RuntimeError catches it there.
  • return after catching the injected exception: Using return inside a generator raises StopIteration, signalling the caller that the generator is done. The caller wraps gen.throw() in a try/except StopIteration to handle this cleanly.
  • generator.throw() vs. generator.close(): close() is a convenience method that calls throw(GeneratorExit). It is the mechanism Python uses to clean up generators when they are garbage-collected. throw() with a custom exception type lets you inject any signal you define – making it a flexible cancellation and communication channel in data pipelines.

Filed Under: Python, Python Exercises

Did you find this page helpful? Let others know about it. Sharing helps me continue to create free Python resources.

TweetF  sharein  shareP  Pin

About Vishal

I’m Vishal Hule, the Founder of PYnative.com. As a Python developer, I enjoy assisting students, developers, and learners. Follow me on Twitter.

Related Tutorial Topics:

Python Python Exercises

All Coding Exercises:

C Exercises
C++ Exercises
Python Exercises

Python Exercises and Quizzes

Free coding exercises and quizzes cover Python basics, data structure, data analytics, and more.

  • 15+ Topic-specific Exercises and Quizzes
  • Each Exercise contains 25+ questions
  • Each Quiz contains 25 MCQ
Exercises
Quizzes

Leave a Reply Cancel reply

your email address will NOT be published. all comments are moderated according to our comment policy.

Use <pre> tag for posting code. E.g. <pre> Your entire code </pre>

In: Python Python Exercises
TweetF  sharein  shareP  Pin

  Python Exercises

  • All Python Exercises
  • Basic Exercises for Beginners
  • Intermediate Python Exercises
  • Input and Output Exercises
  • Loop Exercises
  • Functions Exercises
  • String Exercises
  • List Exercises
  • Dictionary Exercises
  • Set Exercises
  • Tuple Exercises
  • Data Structure Exercises
  • Date and Time Exercises
  • OOP Exercises
  • File Handling Exercises
  • Iterators & Generators Exercises
  • Regex Exercises
  • Python JSON Exercises
  • Random Data Generation Exercises
  • NumPy Exercises
  • Pandas Exercises
  • Matplotlib Exercises
  • Python Database Exercises

 Explore Python

  • Python Tutorials
  • Python Exercises
  • Python Quizzes
  • Python Interview Q&A
  • Python Programs

All Python Topics

Python Basics Python Exercises Python Quizzes Python Interview Python File Handling Python OOP Python Date and Time Python Random Python Regex Python Pandas Python Databases Python MySQL Python PostgreSQL Python SQLite Python JSON

About PYnative

PYnative.com is for Python lovers. Here, You can get Tutorials, Exercises, and Quizzes to practice and improve your Python skills.

Follow Us

To get New Python Tutorials, Exercises, and Quizzes

  • Twitter
  • Facebook
  • Sitemap

Explore Python

  • Learn Python
  • Python Basics
  • Python Databases
  • Python Exercises
  • Python Quizzes
  • Online Python Code Editor
  • Python Tricks

Coding Exercises

  • C Exercises
  • C++ Exercises
  • Python Exercises

Legal Stuff

  • About Us
  • Contact Us

We use cookies to improve your experience. While using PYnative, you agree to have read and accepted our:

  • Terms Of Use
  • Privacy Policy
  • Cookie Policy

Copyright © 2018–2026 pynative.com