Skip to main content

Python try-except-finally Practice Problems & Exercises

Practice: try-except-finally

11 problems4 Easy4 Medium3 Hard40-55 min
← Back to lesson

#1Predict the Execution OrderEasy
try/except/else/finallyexecution flow

Goal: Without running the code, predict exactly which letters print and in what order.

The function calls int("42") inside the try block. Think about:

  • Does this raise an exception?
  • Which clauses run when the try block completes successfully?
  • What is the guaranteed execution order of try, except, else, and finally?

Write your prediction, then run the code to verify.

Solution

int("42") succeeds, so no exception is raised. The execution order is:

  1. try block runs: prints A
  2. No exception, so except is skipped
  3. else runs (because try completed without error): prints C
  4. finally always runs: prints D
  5. Code after the entire block: prints E

The key insight: else runs before finally. The full happy-path order is try then else then finally.

def mystery():
  try:
      x = int("42")
      print("A")
  except ValueError:
      print("B")
  else:
      print("C")
  finally:
      print("D")
  print("E")

mystery()

# Write the output below, one letter per line:
# ???
Expected Output
A
C
D
E
Hints

Hint 1: int('42') succeeds — no exception is raised.

Hint 2: When no exception occurs, the else clause runs after try.

Hint 3: finally always runs, regardless of whether an exception occurred.

#2Predict the Error PathEasy
try/except/else/finallyexecution flow

Goal: Predict the output when the try block raises a ValueError.

This is the counterpart to Problem 1. Now int() receives an invalid string. Think about:

  • Which line in try causes the exception?
  • Does print("A") ever execute?
  • Does the else clause run when an exception was caught?
Solution

int("not_a_number") raises ValueError immediately. Execution jumps to the matching except clause.

  1. try block starts, int(...) raises — print("A") is never reached
  2. except ValueError matches: prints B
  3. else is skipped (an exception occurred)
  4. finally runs: prints D
  5. The exception was handled, so execution continues: prints E

The key insight: else only runs when try completes without any exception. If except catches something, else is skipped entirely.

def mystery_error():
  try:
      x = int("not_a_number")
      print("A")
  except ValueError:
      print("B")
  else:
      print("C")
  finally:
      print("D")
  print("E")

mystery_error()

# Write the output below:
# ???
Expected Output
B
D
E
Hints

Hint 1: int('not_a_number') raises a ValueError.

Hint 2: When an exception is caught, the else clause does NOT run.

Hint 3: finally runs regardless, and since the exception was handled, execution continues after the block.

#3Safe Division with ElseEasy
try/except/elsereturn values

Goal: Implement safe_divide using all four clauses of the try statement.

Requirements:

  • try: perform the division a / b
  • except: catch ZeroDivisionError and TypeError, return None
  • else: print "success" and return the result (only when division works)
  • finally: print "attempted" every time the function is called
Solution
def safe_divide(a, b):
try:
result = a / b
except (ZeroDivisionError, TypeError):
return None
else:
print("success")
return result
finally:
print("attempted")

Key points:

  • The else clause is the right place for the success logic — it only runs when try succeeds
  • finally prints "attempted" even when we return from except or else
  • Notice that finally runs before the return value is delivered to the caller, but the return value from else or except is preserved (unless finally itself returns)
def safe_divide(a, b):
  """Return a / b, or None if division is impossible.
  Print 'success' only when division works.
  Print 'attempted' every time the function is called.
  Use try/except/else/finally — all four clauses."""
  # YOUR CODE HERE
  pass

# Tests
print(safe_divide(10, 2))    # success, attempted, 5.0
print(safe_divide(10, 0))    # attempted, None
print(safe_divide("x", 2))   # attempted, None
Expected Output
success
attempted
5.0
attempted
None
attempted
None
Hints

Hint 1: Put only the division inside try — keep it minimal.

Hint 2: Use else to print 'success' and return the result.

Hint 3: Use finally to print 'attempted' — it runs every time.

Hint 4: Catch both ZeroDivisionError and TypeError in the except clause.

#4Exception Variable ScopingEasy
as bindingvariable scope

Goal: Understand Python 3's scoping rule for exception variables bound with as.

Two questions:

  1. Does print(saved) work after the except block?
  2. Would print(e) work after the except block?

Explain why for each answer.

Solution
  1. print(saved) works fine and prints invalid literal for int() with base 10: 'abc'. The variable saved is a normal local variable — it persists after the except block.

  2. print(e) raises NameError: name 'e' is not defined. Python 3 explicitly deletes the as binding when the except block exits.

Why does Python delete e? Exception objects hold a reference to the traceback, and the traceback holds references to frame locals. If e persisted, it would create a reference cycle that delays garbage collection. Python breaks this cycle by deleting the binding.

The practical rule: always save what you need from the exception to a different variable before the except block ends.

# Predict: does this code print the error message,
# or does it raise a NameError?

try:
  int("abc")
except ValueError as e:
  saved = str(e)

# What happens on this next line?
print(saved)

# And what about this line?
# print(e)

# Explain your answers below as comments.
Expected Output
invalid literal for int() with base 10: 'abc'
Hints

Hint 1: Python 3 deletes the 'as e' variable when the except block exits.

Hint 2: Variables assigned normally inside the except block (like 'saved') are NOT deleted.

Hint 3: The deletion only applies to the exception binding variable itself.


#5Multiple Except Clauses — Order MattersMedium
multiple exceptexception hierarchy

Goal: Fix the exception handler ordering so each error type gets its own specific message.

The current code catches OSError first. Think about the exception hierarchy:

  • OSError is the parent
  • FileNotFoundError is a subclass of OSError
  • PermissionError is a subclass of OSError

Which clause will Python match when a FileNotFoundError is raised?

Solution
def handle_file_error(path):
try:
with open(path) as f:
return f.read()
except FileNotFoundError:
print(f"File not found: {path}")
return None
except PermissionError:
print(f"Permission denied: {path}")
return None
except OSError as e:
print(f"OS error: {e}")
return None

The fix is to put the most specific exceptions first. Since FileNotFoundError and PermissionError are both subclasses of OSError, placing OSError first makes the other two unreachable.

The rule: always order except clauses from most specific to most general. Python does not warn you about unreachable handlers — you need to know the hierarchy.

# This code has a bug: one of the except clauses
# is unreachable. Fix it.

def handle_file_error(path):
  """Open a file and return its contents.
  Handle specific errors with specific messages."""
  try:
      with open(path) as f:
          return f.read()
  except OSError as e:
      print(f"OS error: {e}")
      return None
  except FileNotFoundError:
      print(f"File not found: {path}")
      return None
  except PermissionError:
      print(f"Permission denied: {path}")
      return None

# Test with a nonexistent file
result = handle_file_error("/no/such/file.txt")
print(f"Result: {result}")
Expected Output
File not found: /no/such/file.txt
Result: None
Hints

Hint 1: FileNotFoundError and PermissionError are both subclasses of OSError.

Hint 2: Python checks except clauses top-to-bottom and uses the FIRST match.

Hint 3: A parent class handler catches all its subclasses — put specific before general.

#6Else Clause — Separating ConcernsMedium
else clauseEAFP

Goal: Refactor the function so that ValueError from process_data is NOT accidentally caught.

The problem: both json.loads and process_data can raise ValueError, but the current code catches all ValueError exceptions, hiding bugs in process_data.

Use the else clause to separate "code that might fail with JSON errors" from "code that runs after successful parsing."

Solution
import json

def load_and_process(raw_json):
try:
data = json.loads(raw_json)
except json.JSONDecodeError:
return "Error: invalid JSON"
else:
result = process_data(data)
return result

By moving process_data(data) into else:

  • It only runs when json.loads succeeds (same as before)
  • But any ValueError from process_data is NOT caught by the except clauses above
  • The ValueError propagates to the caller, where it belongs

This is the primary use case for else: keeping the try block minimal and ensuring only the expected exceptions are caught.

# Refactor this function to use the else clause properly.
# Currently, the JSON parsing code is inside the try block,
# which means a ValueError from json.loads could be confused
# with a ValueError from process_data.

import json

def load_and_process(raw_json):
  """Parse JSON and process the data.
  Return processed result, or an error message string."""
  try:
      data = json.loads(raw_json)
      # BUG: if process_data raises ValueError,
      # it gets caught by the except below!
      result = process_data(data)
      return result
  except json.JSONDecodeError:
      return "Error: invalid JSON"
  except ValueError:
      return "Error: invalid value in JSON"

def process_data(data):
  """Process data dict. Raises ValueError if 'count' is negative."""
  if data.get("count", 0) < 0:
      raise ValueError("count cannot be negative")
  return f"Processed {data.get('count', 0)} items"

# Tests — after your fix:
# Valid JSON, valid data -> should return processed result
print(load_and_process('{"count": 5}'))
# Invalid JSON -> should return JSON error
print(load_and_process('not json'))
# Valid JSON, invalid data -> should RAISE ValueError (not catch it!)
try:
  print(load_and_process('{"count": -1}'))
except ValueError as e:
  print(f"Correctly raised: {e}")
Expected Output
Processed 5 items
Error: invalid JSON
Correctly raised: count cannot be negative
Hints

Hint 1: Move process_data(data) into the else clause so it is NOT guarded by the except clauses.

Hint 2: Exceptions raised in else are NOT caught by the except clauses of the same try statement.

Hint 3: The try block should contain ONLY the code that might raise the exceptions you want to catch.

#7Finally Guarantees — Resource CleanupMedium
finallycleanupresource management

Goal: Implement process_with_resources so that both resources are always released, even when processing raises an exception.

Requirements:

  • Acquire "database" and "cache" (in that order) using tracker.acquire()
  • If should_fail is True, raise RuntimeError("processing failed")
  • In finally, release both resources in reverse order (cache first, then database)
  • Guard each release with a None check
  • Return "done" on the success path
Solution
def process_with_resources(tracker, should_fail=False):
r1 = None
r2 = None
try:
r1 = tracker.acquire("database")
r2 = tracker.acquire("cache")
if should_fail:
raise RuntimeError("processing failed")
return "done"
finally:
if r2 is not None:
tracker.release(r2)
if r1 is not None:
tracker.release(r1)

Key points:

  • Resources are released in reverse order (LIFO), which is the standard convention
  • Each release is guarded by a None check — if acquire("cache") failed, r2 would still be None
  • finally runs even though the success path uses return and the failure path raises
  • The return value "done" is still delivered to the caller despite finally running (because finally does not itself return)
# Implement a function that tracks resource acquisition and release.
# The function must GUARANTEE cleanup even if processing fails.

class ResourceTracker:
  """Tracks which resources are currently open."""
  def __init__(self):
      self.open_resources = []

  def acquire(self, name):
      self.open_resources.append(name)
      print(f"  Acquired: {name}")
      return name

  def release(self, name):
      self.open_resources.remove(name)
      print(f"  Released: {name}")

tracker = ResourceTracker()

def process_with_resources(tracker, should_fail=False):
  """Acquire two resources, process them, release both.
  Must release resources even if processing raises an error.
  Return 'done' on success."""
  r1 = None
  r2 = None
  # YOUR CODE HERE — use try/finally
  pass

# Test 1: success path
print("=== Success ===")
result = process_with_resources(tracker, should_fail=False)
print(f"Result: {result}")
print(f"Open resources: {tracker.open_resources}")

# Test 2: failure path
print("\n=== Failure ===")
try:
  process_with_resources(tracker, should_fail=True)
except RuntimeError as e:
  print(f"Caught: {e}")
print(f"Open resources: {tracker.open_resources}")
Expected Output
=== Success ===
Acquired: database
Acquired: cache
Released: cache
Released: database
Result: done
Open resources: []

=== Failure ===
Acquired: database
Acquired: cache
Released: cache
Released: database
Caught: processing failed
Open resources: []
Hints

Hint 1: Acquire both resources, then wrap the processing in try/finally.

Hint 2: In finally, release resources in reverse order (LIFO — last acquired, first released).

Hint 3: Check if each resource is not None before releasing — the acquisition itself might have failed.

Hint 4: If should_fail is True, raise RuntimeError('processing failed') to simulate an error.

#8Finally Overrides Return — The TrapMedium
finallyreturngotcha

Goal: Understand how finally interacts with return statements.

Predict the return value and printed output for each function. Then identify which function has a dangerous bug and explain why.

Solution

func_a returns 1. The finally block prints "cleanup a" but does not override the return. Output: cleanup a then func_a: 1.

func_b returns 2. The exception is caught, except returns 2, finally prints "cleanup b" but does not override the return. Output: cleanup b then func_b: 2.

func_c returns 4 — NOT 3. The return 4 in finally overrides the return 3 from the except block. This is the bug.

Why it is dangerous: if the except clause had re-raised the exception instead of returning, return 4 in finally would silently suppress the exception. The caller would receive 4 and never know an error occurred.

The fix: remove the return from finally. Use finally only for cleanup (closing files, releasing locks, printing diagnostics), never for returning values.

def func_c_fixed():
try:
raise ValueError("real error")
except ValueError:
return 3
finally:
print("cleanup c") # Cleanup only — no return
# Predict the output of ALL THREE functions.
# Then fix the one that has a bug.

def func_a():
  try:
      return 1
  finally:
      print("cleanup a")

def func_b():
  try:
      raise ValueError("oops")
  except ValueError:
      return 2
  finally:
      print("cleanup b")

def func_c():
  try:
      raise ValueError("real error")
  except ValueError:
      return 3
  finally:
      return 4  # <-- Is this OK?

print(f"func_a: {func_a()}")
print(f"func_b: {func_b()}")
print(f"func_c: {func_c()}")

# Questions:
# 1. What does each function return?
# 2. Which function has a bug? Why?
# 3. How would you fix it?
Expected Output
cleanup a
func_a: 1
cleanup b
func_b: 2
func_c: 4
Hints

Hint 1: finally always runs — even when there is a return in try or except.

Hint 2: If finally itself contains a return, that return OVERRIDES any previous return.

Hint 3: A return in finally also silently suppresses any exception that was in flight.


#9Exception Chaining with fromHard
exception chainingraise from__cause__

Goal: Build a config loader that uses raise ... from ... to chain exceptions, preserving the full error context.

Exception chaining with from sets the __cause__ attribute, which Python displays in tracebacks as "The above exception was the direct cause of the following exception". This is essential in production code where you want domain-specific errors but need to preserve debugging information.

Requirements:

  • For each required key, try to look it up and convert to int
  • If the key is missing, raise ConfigError chained from the KeyError
  • If the value is not a valid integer, raise ConfigError chained from the ValueError
  • If the integer value is negative, raise a ValueError yourself, then chain it into ConfigError
  • Return a dict of validated integer values on success
Solution
class ConfigError(Exception):
pass

def load_config(config_dict, required_keys):
result = {}
for key in required_keys:
try:
raw_value = config_dict[key]
except KeyError as e:
raise ConfigError(
f"Missing required config key: '{key}'"
) from e

try:
value = int(raw_value)
except ValueError as e:
raise ConfigError(
f"Invalid value for '{key}': expected integer"
) from e

try:
if value < 0:
raise ValueError(f"{key} must be non-negative")
except ValueError as e:
raise ConfigError(
f"Invalid value for '{key}': must be non-negative"
) from e

result[key] = value

return result

Key points:

  • raise X from Y sets X.__cause__ = Y, creating an explicit exception chain
  • Each step has its own try/except so we know exactly which operation failed
  • The original exception is preserved for debugging (visible in full tracebacks)
  • Domain-specific ConfigError gives callers a single type to catch, while __cause__ provides the root cause
# Implement a configuration loader that uses exception chaining
# to preserve the original error while raising a domain-specific exception.

class ConfigError(Exception):
  """Raised when configuration cannot be loaded."""
  pass

def load_config(config_dict, required_keys):
  """Validate that all required keys exist and have valid values.

  Raises ConfigError with chained original exception when:
  - A required key is missing (chain from KeyError)
  - A value cannot be converted to int (chain from ValueError)
  - A value is negative (chain from ValueError)

  Returns dict of validated integer values.
  """
  # YOUR CODE HERE
  pass

# Tests
import traceback

# Test 1: success
config = load_config({"port": "8080", "workers": "4"}, ["port", "workers"])
print(f"Config: {config}")

# Test 2: missing key — should show chained KeyError
print("\n--- Missing key ---")
try:
  load_config({"port": "8080"}, ["port", "workers"])
except ConfigError as e:
  print(f"ConfigError: {e}")
  print(f"Caused by: {type(e.__cause__).__name__}: {e.__cause__}")

# Test 3: invalid value — should show chained ValueError
print("\n--- Invalid value ---")
try:
  load_config({"port": "not_a_number"}, ["port"])
except ConfigError as e:
  print(f"ConfigError: {e}")
  print(f"Caused by: {type(e.__cause__).__name__}: {e.__cause__}")

# Test 4: negative value — should show chained ValueError
print("\n--- Negative value ---")
try:
  load_config({"port": "-1"}, ["port"])
except ConfigError as e:
  print(f"ConfigError: {e}")
  print(f"Caused by: {type(e.__cause__).__name__}: {e.__cause__}")
Expected Output
Config: {'port': 8080, 'workers': 4}

--- Missing key ---
ConfigError: Missing required config key: 'workers'
Caused by: KeyError: 'workers'

--- Invalid value ---
ConfigError: Invalid value for 'port': expected integer
Caused by: ValueError: invalid literal for int() with base 10: 'not_a_number'

--- Negative value ---
ConfigError: Invalid value for 'port': must be non-negative
Caused by: ValueError: port must be non-negative
Hints

Hint 1: Use 'raise ConfigError(...) from original_exception' to set __cause__.

Hint 2: Catch KeyError when accessing config_dict[key] for missing keys.

Hint 3: Catch ValueError when calling int(value) for non-integer values.

Hint 4: For negative values, raise your own ValueError first, then chain it into ConfigError.

#10Retry with Cleanup GuaranteeHard
retry patternfinallycleanup

Goal: Implement a retry function that guarantees cleanup runs after every attempt, whether it succeeds or fails.

This pattern is common in production systems: reconnecting to databases, retrying API calls, or restarting workers. The setup function prepares the environment, and the cleanup function tears it down — and cleanup must be guaranteed.

The log should show the exact interleaving: setup, attempt, cleanup repeated for each try.

Solution
def retry_with_cleanup(func, setup, cleanup, max_attempts=3):
last_exception = None
for attempt in range(max_attempts):
setup()
try:
result = func()
return result
except Exception as e:
last_exception = e
if attempt == max_attempts - 1:
raise
finally:
cleanup()

Key design decisions:

  • setup() is called before the try block each time — if setup fails, cleanup does not run (there is nothing to clean up)
  • cleanup() is inside finally, guaranteeing it runs after every attempt
  • On success, return result triggers finally (cleanup runs), then the value is returned
  • On the last failed attempt, raise re-raises the exception; finally still runs before propagation
  • We do not need the last_exception variable with this approach since we raise inside except on the final attempt

Alternative approach that explicitly re-raises after the loop:

def retry_with_cleanup(func, setup, cleanup, max_attempts=3):
last_exception = None
for attempt in range(max_attempts):
setup()
try:
return func()
except Exception as e:
last_exception = e
finally:
cleanup()
raise last_exception

Both work. The second makes the "all retries exhausted" case more explicit.

# Build a retry_with_cleanup function that:
# 1. Retries a callable up to max_attempts times
# 2. Calls a cleanup function after EVERY attempt (success or failure)
# 3. Calls a setup function before EVERY attempt
# 4. Returns the result on success
# 5. Raises the last exception if all attempts fail

attempt_log = []

def retry_with_cleanup(func, setup, cleanup, max_attempts=3):
  """Retry func with setup/cleanup around each attempt.

  Args:
      func: callable to retry
      setup: called before each attempt (no args)
      cleanup: called after each attempt (no args), even on failure
      max_attempts: number of attempts before giving up

  Returns the result of func() on success.
  Raises the last exception if all attempts fail.
  """
  # YOUR CODE HERE
  pass

# --- Test infrastructure ---
call_count = 0

def flaky_operation():
  global call_count
  call_count += 1
  attempt_log.append(f"attempt-{call_count}")
  if call_count < 3:
      raise ConnectionError(f"fail #{call_count}")
  return "success"

def setup():
  attempt_log.append("setup")

def cleanup():
  attempt_log.append("cleanup")

# Test 1: succeeds on third attempt
call_count = 0
attempt_log.clear()
result = retry_with_cleanup(flaky_operation, setup, cleanup, max_attempts=3)
print(f"Result: {result}")
print(f"Log: {attempt_log}")

# Test 2: all attempts fail
call_count = 0
attempt_log.clear()

def always_fails():
  global call_count
  call_count += 1
  attempt_log.append(f"attempt-{call_count}")
  raise RuntimeError(f"permanent failure #{call_count}")

print("\n--- All attempts fail ---")
try:
  retry_with_cleanup(always_fails, setup, cleanup, max_attempts=2)
except RuntimeError as e:
  print(f"Caught: {e}")
print(f"Log: {attempt_log}")
Expected Output
Result: success
Log: ['setup', 'attempt-1', 'cleanup', 'setup', 'attempt-2', 'cleanup', 'setup', 'attempt-3', 'cleanup']

--- All attempts fail ---
Caught: permanent failure #2
Log: ['setup', 'attempt-1', 'cleanup', 'setup', 'attempt-2', 'cleanup']
Hints

Hint 1: Use a for loop over range(max_attempts) for the retry logic.

Hint 2: Call setup() before each try block, and cleanup() inside finally.

Hint 3: Save the exception from each failed attempt — re-raise the last one after the loop.

Hint 4: Return immediately on success — the finally block still runs before the return is delivered.

#11Context Manager from try/finallyHard
context managertry/finallycleanup

Goal: Convert the manual try/finally cleanup pattern into two composable context managers using contextlib.contextmanager.

This is the production-grade approach to resource management in Python. Instead of remembering to close connections and cursors manually, context managers guarantee cleanup automatically.

Requirements:

  • db_connection(db_path) connects and yields the connection; closes in finally
  • db_cursor(connection) creates and yields a cursor; closes in finally
  • Both must guarantee cleanup even when the body of the with block raises an exception
  • The query_database function should use nested with statements
Solution
from contextlib import contextmanager

@contextmanager
def db_connection(db_path):
conn = FakeDB.connect(db_path)
try:
yield conn
finally:
conn.close()

@contextmanager
def db_cursor(connection):
cursor = connection.cursor()
try:
yield cursor
finally:
cursor.close()

How it works:

  1. Code before yield runs when entering the with block
  2. The yield value becomes the as variable in with ... as var
  3. Code after yield (in finally) runs when exiting the with block
  4. Because we use try/finally around the yield, cleanup is guaranteed even if the body raises

The composable design means you can mix and match:

  • Use db_connection alone when you need a connection but not a cursor
  • Stack them with nested with blocks for full query patterns
  • Each resource's cleanup is independent — cursor closes before connection

This replaces 15 lines of manual try/finally with two reusable, composable 5-line context managers.

# Convert this try/finally pattern into a context manager
# using contextlib.contextmanager.

# Original pattern (do NOT modify this — it is for reference):
def query_database_original(db_path, query):
  """The old way: manual try/finally cleanup."""
  conn = None
  cursor = None
  try:
      conn = FakeDB.connect(db_path)
      cursor = conn.cursor()
      cursor.execute(query)
      return cursor.fetchall()
  except FakeDB.OperationalError as e:
      print(f"Query failed: {e}")
      return []
  finally:
      if cursor is not None:
          cursor.close()
      if conn is not None:
          conn.close()

# --- Fake database for testing ---
class FakeDB:
  class OperationalError(Exception):
      pass

  @staticmethod
  def connect(path):
      print(f"  Connected to {path}")
      return FakeConnection(path)

class FakeConnection:
  def __init__(self, path):
      self.path = path
      self.closed = False
  def cursor(self):
      return FakeCursor(self)
  def close(self):
      self.closed = True
      print(f"  Connection closed")

class FakeCursor:
  def __init__(self, conn):
      self.conn = conn
      self.closed = False
  def execute(self, query):
      print(f"  Executing: {query}")
      if "BAD" in query:
          raise FakeDB.OperationalError("syntax error")
  def fetchall(self):
      return [("row1",), ("row2",)]
  def close(self):
      self.closed = True
      print(f"  Cursor closed")

# YOUR TASK: implement db_connection and db_cursor as context managers
from contextlib import contextmanager

@contextmanager
def db_connection(db_path):
  """Context manager that connects and guarantees close."""
  # YOUR CODE HERE
  pass

@contextmanager
def db_cursor(connection):
  """Context manager that creates a cursor and guarantees close."""
  # YOUR CODE HERE
  pass

def query_database(db_path, query):
  """The new way: context managers handle cleanup."""
  with db_connection(db_path) as conn:
      with db_cursor(conn) as cursor:
          cursor.execute(query)
          return cursor.fetchall()

# Tests
print("=== Successful query ===")
rows = query_database("mydb.sqlite", "SELECT * FROM users")
print(f"Rows: {rows}")

print("\n=== Failed query ===")
try:
  query_database("mydb.sqlite", "BAD QUERY")
except FakeDB.OperationalError as e:
  print(f"Caught: {e}")
Expected Output
=== Successful query ===
Connected to mydb.sqlite
Executing: SELECT * FROM users
Cursor closed
Connection closed
Rows: [('row1',), ('row2',)]

=== Failed query ===
Connected to mydb.sqlite
Executing: BAD QUERY
Cursor closed
Connection closed
Caught: syntax error
Hints

Hint 1: In db_connection: connect, yield the connection, then close in a finally block.

Hint 2: In db_cursor: create cursor, yield it, then close in a finally block.

Hint 3: The yield statement is where the 'with' block body executes.

Hint 4: Use try/finally around the yield to guarantee cleanup even when exceptions occur.

© 2026 EngineersOfAI. All rights reserved.