Python try-except-finally Practice Problems & Exercises
Practice: try-except-finally
← Back to lessonGoal: 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
tryblock completes successfully? - What is the guaranteed execution order of
try,except,else, andfinally?
Write your prediction, then run the code to verify. The key insight: Solution
int("42") succeeds, so no exception is raised. The execution order is:
try block runs: prints Aexcept is skippedelse runs (because try completed without error): prints Cfinally always runs: prints Delse 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
EHints
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.
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
trycauses the exception? - Does
print("A")ever execute? - Does the
elseclause run when an exception was caught?
Solution
int("not_a_number") raises ValueError immediately. Execution jumps to the matching except clause.
tryblock starts,int(...)raises —print("A")is never reachedexcept ValueErrormatches: prints Belseis skipped (an exception occurred)finallyruns: prints D- 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
EHints
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.
Goal: Implement safe_divide using all four clauses of the try statement.
Requirements:
try: perform the divisiona / bexcept: catchZeroDivisionErrorandTypeError, returnNoneelse: 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
elseclause is the right place for the success logic — it only runs whentrysucceeds finallyprints"attempted"even when wereturnfromexceptorelse- Notice that
finallyruns before the return value is delivered to the caller, but the return value fromelseorexceptis preserved (unlessfinallyitself 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, NoneExpected Output
success
attempted
5.0
attempted
None
attempted
NoneHints
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.
Goal: Understand Python 3's scoping rule for exception variables bound with as.
Two questions:
- Does
print(saved)work after theexceptblock? - Would
print(e)work after theexceptblock?
Explain why for each answer. Why does Python delete The practical rule: always save what you need from the exception to a different variable before the Solution
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.print(e) raises NameError: name 'e' is not defined. Python 3 explicitly deletes the as binding when the except block exits.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.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.
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:
OSErroris the parentFileNotFoundErroris a subclass ofOSErrorPermissionErroris a subclass ofOSError
Which clause will Python match when a The fix is to put the most specific exceptions first. Since The rule: always order FileNotFoundError is raised?Solution
FileNotFoundError and PermissionError are both subclasses of OSError, placing OSError first makes the other two unreachable.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: NoneHints
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.
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 By moving This is the primary use case for else clause to separate "code that might fail with JSON errors" from "code that runs after successful parsing."Solution
process_data(data) into else:
json.loads succeeds (same as before)ValueError from process_data is NOT caught by the except clauses aboveValueError propagates to the caller, where it belongselse: 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 negativeHints
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.
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) usingtracker.acquire() - If
should_failisTrue, raiseRuntimeError("processing failed") - In
finally, release both resources in reverse order (cache first, then database) - Guard each release with a
Nonecheck - 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
Nonecheck — ifacquire("cache")failed,r2would still beNone finallyruns even though the success path usesreturnand the failure path raises- The return value
"done"is still delivered to the caller despitefinallyrunning (becausefinallydoes not itselfreturn)
# 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.
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. func_a returns func_b returns func_c returns Why it is dangerous: if the The fix: remove the Solution
1. The finally block prints "cleanup a" but does not override the return. Output: cleanup a then func_a: 1.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.4 — NOT 3. The return 4 in finally overrides the return 3 from the except block. This is the bug.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.return from finally. Use finally only for cleanup (closing files, releasing locks, printing diagnostics), never for returning values.
# 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: 4Hints
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.
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
ConfigErrorchained from theKeyError - If the value is not a valid integer, raise
ConfigErrorchained from theValueError - If the integer value is negative, raise a
ValueErroryourself, then chain it intoConfigError - 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 YsetsX.__cause__ = Y, creating an explicit exception chain- Each step has its own
try/exceptso we know exactly which operation failed - The original exception is preserved for debugging (visible in full tracebacks)
- Domain-specific
ConfigErrorgives 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-negativeHints
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.
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: Key design decisions: Alternative approach that explicitly re-raises after the loop: Both work. The second makes the "all retries exhausted" case more explicit.setup, attempt, cleanup repeated for each try.Solution
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 attemptreturn result triggers finally (cleanup runs), then the value is returnedraise re-raises the exception; finally still runs before propagationlast_exception variable with this approach since we raise inside except on the final attempt
# 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.
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 infinallydb_cursor(connection)creates and yields a cursor; closes infinally- Both must guarantee cleanup even when the body of the
withblock raises an exception - The
query_databasefunction should use nestedwithstatements
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:
- Code before
yieldruns when entering thewithblock - The
yieldvalue becomes theasvariable inwith ... as var - Code after
yield(infinally) runs when exiting thewithblock - Because we use
try/finallyaround theyield, cleanup is guaranteed even if the body raises
The composable design means you can mix and match:
- Use
db_connectionalone when you need a connection but not a cursor - Stack them with nested
withblocks 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 errorHints
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.
