Python Debugging Strategies Practice Problems & Exercises
Practice: Debugging Strategies
← Back to lessonEasy
A function computes the median of a list but the developer is not sure if it works correctly. Add strategic print statements after each step to trace the data flow. Label every print so the output is self-documenting.
def find_median(numbers):
sorted_nums = sorted(numbers)
mid = len(sorted_nums) // 2
return sorted_nums[mid]
data = [3, 7, 2, 8, 1]
print("Input:", data)
sorted_version = sorted(data)
print("After sort:", sorted_version)
mid_index = len(sorted_version) // 2
print("Middle index:", mid_index)
median = sorted_version[mid_index]
print("Median value:", median)
result = find_median(data)
print("Result:", result)Solution
def find_median(numbers):
sorted_nums = sorted(numbers)
mid = len(sorted_nums) // 2
return sorted_nums[mid]
data = [3, 7, 2, 8, 1]
print("Input:", data)
sorted_version = sorted(data)
print("After sort:", sorted_version)
mid_index = len(sorted_version) // 2
print("Middle index:", mid_index)
median = sorted_version[mid_index]
print("Median value:", median)
result = find_median(data)
print("Result:", result)
Why strategic prints work:
- Each print is labeled (
"Input:","After sort:", etc.) so you instantly know which step produced which output. - You trace the data transformation pipeline: raw input to sorted to index to value. If any step produces unexpected output, you have isolated the bug to a single operation.
- This is the fastest debugging technique for simple data-flow bugs. No setup, no tools, just visibility.
The anti-pattern: print(x) with no label. When you have 10 unlabeled prints, you waste time matching outputs to code lines.
Key insight: For odd-length lists, len // 2 gives the correct middle index. For [1, 2, 3, 7, 8], index 2 holds value 3, which is the median. This function has a subtle bug for even-length lists (it does not average the two middle values), but strategic prints helped confirm the odd-length case works correctly.
Expected Output
Input: [3, 7, 2, 8, 1]\nAfter sort: [1, 2, 3, 7, 8]\nMiddle index: 2\nMedian value: 3\nResult: 3Hints
Hint 1: Add a print statement after each significant operation to trace how the data transforms step by step.
Hint 2: The key to effective print debugging is labeling every output so you know which print produced which line.
Analyze the following buggy code. Without running it first, predict the error type, the function where it occurs, and the root cause. Then write code that prints your analysis.
def get_user_email(user_data):
"""Extract email from user dictionary."""
return user_data["email"]
def process_user(raw_data):
email = get_user_email(raw_data)
return email.lower()
# The bug: the key uses a hyphen
user = {"name": "Alice", "e-mail": "[email protected]", "age": 30}
# Analyze without running the buggy code
print("Error type: KeyError")
print("Error message: 'email'")
print("Function where error occurred: get_user_email")
print("Root cause: The dictionary has 'e-mail' (with hyphen), not 'email'")Solution
def get_user_email(user_data):
"""Extract email from user dictionary."""
return user_data["email"]
def process_user(raw_data):
email = get_user_email(raw_data)
return email.lower()
# Analysis
print("Error type: KeyError")
print("Error message: 'email'")
print("Function where error occurred: get_user_email")
print("Root cause: The dictionary has 'e-mail' (with hyphen), not 'email'")
How to read the traceback that would occur:
Traceback (most recent call last):
File "app.py", line 10, in <module>
result = process_user(user)
File "app.py", line 6, in process_user
email = get_user_email(raw_data)
File "app.py", line 3, in get_user_email
return user_data["email"]
KeyError: 'email'
Reading strategy (always bottom-up):
- Last line:
KeyError: 'email'— the key"email"does not exist in the dictionary. - Frame above:
get_user_emailat line 3 — this is where the access happens. - Frame above:
process_userat line 6 — this is the caller. - Frame above: module level — this is where
process_userwas called.
The fix: Either rename the dictionary key to "email" or change the lookup to user_data["e-mail"]. In production, use .get("email") with a default value to avoid crashes on missing keys.
Expected Output
Error type: KeyError\nError message: 'email'\nFunction where error occurred: get_user_email\nRoot cause: The dictionary has 'e-mail' (with hyphen), not 'email'Hints
Hint 1: Tracebacks read bottom-up: the last line shows the exception type and message, the lines above show the call stack from most recent to oldest.
Hint 2: A `KeyError` means you tried to access a dictionary key that does not exist. The missing key is shown in the error message.
A function that calculates a total price is silently producing wrong results. The bug is a type mismatch that is invisible without checking types. Use type-aware print debugging to find and fix it.
def calculate_total(price, tax):
return price + tax
# The bug: price comes from user input as a string
price = "42"
tax = 10
# Type-aware debugging
print("value: " + str(price) + ", type: " + str(type(price)))
print("Bug found: price is a string, not a number")
# Fix it
fixed_price = int(price)
print("Fixed value: " + str(fixed_price) + ", type: " + str(type(fixed_price)))
total = calculate_total(fixed_price, tax)
print("Total: " + str(total))Solution
def calculate_total(price, tax):
return price + tax
price = "42"
tax = 10
# Type-aware debugging: always check types
print("value: " + str(price) + ", type: " + str(type(price)))
print("Bug found: price is a string, not a number")
fixed_price = int(price)
print("Fixed value: " + str(fixed_price) + ", type: " + str(type(fixed_price)))
total = calculate_total(fixed_price, tax)
print("Total: " + str(total))
Why this bug is dangerous:
Without the type check, calculate_total("42", 10) would raise TypeError: can only concatenate str (not "int") to str. But in some cases, Python silently does the wrong thing — for example, "42" * 2 gives "4242" instead of 84.
The print debugging rule: Never print just the value. Always include type(). A value that looks correct may be the wrong type entirely.
Production pattern: Use type hints and runtime validation (e.g., Pydantic) to catch these at the boundary where data enters your system, not deep inside calculation functions.
Expected Output
value: 42, type: <class 'str'>\nBug found: price is a string, not a number\nFixed value: 42, type: <class 'int'>\nTotal: 52Hints
Hint 1: Always print both the value AND its type. A string `"42"` looks identical to an integer `42` in normal print output.
Hint 2: Use `type()` alongside every value inspection to catch type mismatches that are invisible to the naked eye.
When Python shows "The above exception was the direct cause of the following exception", it means exceptions are chained. Write code that creates a chained exception and then inspects both the outer and inner errors programmatically.
def parse_config_value(raw):
try:
return int(raw)
except ValueError as e:
raise RuntimeError("Failed to parse config value") from e
# Catch and inspect the chain
try:
parse_config_value("abc")
except RuntimeError as outer:
inner = outer.__cause__
print("Original error: " + type(inner).__name__ + " (" + str(inner) + ")")
print("Wrapped error: " + type(outer).__name__ + " (" + str(outer) + ")")
chain_length = 1
current = outer
while current.__cause__ is not None:
chain_length += 1
current = current.__cause__
print("Chain length: " + str(chain_length))
print("The __cause__ attribute links the RuntimeError back to the ValueError")Solution
def parse_config_value(raw):
try:
return int(raw)
except ValueError as e:
raise RuntimeError("Failed to parse config value") from e
try:
parse_config_value("abc")
except RuntimeError as outer:
inner = outer.__cause__
print("Original error: " + type(inner).__name__ + " (" + str(inner) + ")")
print("Wrapped error: " + type(outer).__name__ + " (" + str(outer) + ")")
chain_length = 1
current = outer
while current.__cause__ is not None:
chain_length += 1
current = current.__cause__
print("Chain length: " + str(chain_length))
print("The __cause__ attribute links the RuntimeError back to the ValueError")
Exception chaining in Python:
raise X from YsetsX.__cause__ = Y— this is explicit chaining.- If you raise inside an
exceptblock withoutfrom, Python setsX.__context__ = Y— this is implicit chaining. - The traceback shows both:
"The above exception was the direct cause..."for explicit,"During handling of the above exception..."for implicit.
Why this matters for debugging: In production code with many layers (HTTP handler catches DB error, wraps it in API error), the root cause is often buried several levels deep. Walking the __cause__ chain programmatically lets you log the original error without losing context.
Expected Output
Original error: ValueError (invalid literal for int() with base 10: 'abc')\nWrapped error: RuntimeError (Failed to parse config value)\nChain length: 2\nThe __cause__ attribute links the RuntimeError back to the ValueErrorHints
Hint 1: Python chains exceptions using `raise X from Y`. The original exception is stored in the `__cause__` attribute of the new exception.
Hint 2: Use a try/except block to catch the outer exception, then inspect its `__cause__` to find the original error.
Medium
Build a debug_trace decorator that automatically prints function name, arguments, return value, and execution time for every call. This is a reusable alternative to manually adding print statements.
import time
def debug_trace(func):
def wrapper(*args, **kwargs):
args_str = ", ".join(str(a) for a in args)
if kwargs:
kwargs_str = ", ".join(
str(k) + "=" + str(v) for k, v in kwargs.items()
)
if args_str:
args_str = args_str + ", " + kwargs_str
else:
args_str = kwargs_str
print("[DEBUG] Calling " + func.__name__ + "(" + args_str + ")")
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print("[DEBUG] " + func.__name__ + " returned " + str(result) + " in " + format(elapsed, ".3f") + "s")
return result
return wrapper
@debug_trace
def add(a, b):
return a + b
results = []
results.append(add(3, 5))
results.append(add(10, 20))
print("Results: " + str(results))Solution
import time
def debug_trace(func):
def wrapper(*args, **kwargs):
args_str = ", ".join(str(a) for a in args)
if kwargs:
kwargs_str = ", ".join(
str(k) + "=" + str(v) for k, v in kwargs.items()
)
if args_str:
args_str = args_str + ", " + kwargs_str
else:
args_str = kwargs_str
print("[DEBUG] Calling " + func.__name__ + "(" + args_str + ")")
start = time.perf_counter()
result = func(*args, **kwargs)
elapsed = time.perf_counter() - start
print("[DEBUG] " + func.__name__ + " returned " + str(result) + " in " + format(elapsed, ".3f") + "s")
return result
return wrapper
@debug_trace
def add(a, b):
return a + b
results = []
results.append(add(3, 5))
results.append(add(10, 20))
print("Results: " + str(results))
Why decorators beat manual prints:
- Zero code modification: You add
@debug_traceand every call is traced. Remove the decorator and all tracing disappears. - Consistent format: Every function gets the same output format — name, args, return, time. No risk of forgetting a label.
- Reusable: One decorator works for any function signature thanks to
*args, **kwargs.
Production improvement: Replace print with logging.debug() so tracing can be toggled via log level without removing the decorator. Add functools.wraps(func) to preserve the original function's __name__ and __doc__.
Expected Output
[DEBUG] Calling add(3, 5)\n[DEBUG] add returned 8 in 0.000s\n[DEBUG] Calling add(10, 20)\n[DEBUG] add returned 30 in 0.000s\nResults: [8, 30]Hints
Hint 1: Write a decorator that wraps the function call with print statements showing the function name, arguments, return value, and elapsed time.
Hint 2: Use `time.perf_counter()` to measure execution time. Use `*args` and `**kwargs` to handle any function signature.
A data pipeline has 4 steps: filter, transform, aggregate, format. The final result is wrong. Use bisection debugging to isolate which step introduced the bug by checking intermediate results.
def pipeline(data):
# Step 0: Input
numbers = data
# Step 1: Filter even numbers
filtered = [x for x in numbers if x % 2 == 0]
# Step 2: Double each value
doubled = [x * 2 for x in filtered]
# Step 3: Sum (BUG - should sum filtered, not doubled)
result = sum(doubled)
return result
data = [1, 2, 3, 4, 5]
# Bisection: check each step
print("Testing step 0: input=" + str(data) + " -> OK")
filtered = [x for x in data if x % 2 == 0]
print("Testing step 1: filtered=" + str(filtered) + " -> OK")
doubled = [x * 2 for x in filtered]
print("Testing step 2: doubled=" + str(doubled) + " -> OK")
result = sum(doubled)
expected = 6 # sum of filtered [2, 4] should be 6
if result != expected:
print("Testing step 3: result=" + str(result) + " -> BUG! Expected " + str(expected))
else:
print("Testing step 3: result=" + str(result) + " -> OK")
print("Bug is between step 2 (doubled) and step 3 (sum)")
print("Root cause: sum should use original filtered list, not doubled")Solution
def pipeline(data):
numbers = data
filtered = [x for x in numbers if x % 2 == 0]
doubled = [x * 2 for x in filtered]
result = sum(doubled) # BUG: should be sum(filtered)
return result
data = [1, 2, 3, 4, 5]
# Bisection debugging: check each step's output
print("Testing step 0: input=" + str(data) + " -> OK")
filtered = [x for x in data if x % 2 == 0]
print("Testing step 1: filtered=" + str(filtered) + " -> OK")
doubled = [x * 2 for x in filtered]
print("Testing step 2: doubled=" + str(doubled) + " -> OK")
result = sum(doubled)
expected = 6
if result != expected:
print("Testing step 3: result=" + str(result) + " -> BUG! Expected " + str(expected))
else:
print("Testing step 3: result=" + str(result) + " -> OK")
print("Bug is between step 2 (doubled) and step 3 (sum)")
print("Root cause: sum should use original filtered list, not doubled")
The bisection method:
- A pipeline has N steps. Check the output at step N/2.
- If correct, the bug is in steps N/2+1 to N. If wrong, it is in steps 1 to N/2.
- Repeat, halving the search space each time.
For 4 steps, you need at most 2 checks (O(log N)) instead of checking all 4 steps sequentially.
In this example, the bug is a logic error: the function doubles the filtered values (step 2) but then sums the doubled values (step 3) when the intent was to sum the original filtered values. Bisection isolates the bug to step 3 — the sum input is wrong, not the sum function itself.
Real-world application: Data pipelines in ETL, ML feature engineering, and API request processing all follow this pattern. When the final output is wrong, bisection debugging is faster than reading every line of code.
Expected Output
Testing step 0: input=[1, 2, 3, 4, 5] -> OK\nTesting step 1: filtered=[2, 4] -> OK\nTesting step 2: doubled=[4, 8] -> OK\nTesting step 3: result=12 -> BUG! Expected 6\nBug is between step 2 (doubled) and step 3 (sum)\nRoot cause: sum should use original filtered list, not doubledHints
Hint 1: Bisection debugging means testing the output at the midpoint of a pipeline. If the midpoint is correct, the bug is in the second half. If wrong, the first half.
Hint 2: A data pipeline has discrete steps. Check the output of each step independently to isolate which transformation introduces the error.
The rubber duck method means explaining your code line by line, stating what must be true at each step. Translate this into code by adding invariant checks (assertions) after every significant operation in an order-processing function.
def process_order(items, discount_rate):
# Invariant 1: items must be a list
check1 = isinstance(items, list)
print("Invariant 1: items is a list -> " + ("PASS" if check1 else "FAIL"))
# Invariant 2: all items are positive numbers
check2 = all(item > 0 for item in items)
print("Invariant 2: all items are positive -> " + ("PASS" if check2 else "FAIL"))
# Invariant 3: discount is between 0 and 1
check3 = 0 <= discount_rate <= 1
print("Invariant 3: discount is between 0 and 1 -> " + ("PASS" if check3 else "FAIL"))
# Calculate subtotal
subtotal = sum(items)
# Invariant 4: subtotal should equal sum of items
check4 = subtotal == sum(items)
print("Invariant 4: subtotal equals sum of items -> " + ("PASS" if check4 else "FAIL"))
print("Subtotal: " + str(subtotal))
# Apply discount
discount_amount = subtotal * discount_rate
# Invariant 5: discount amount is non-negative
check5 = discount_amount >= 0
print("Invariant 5: discount_amount is non-negative -> " + ("PASS" if check5 else "FAIL"))
# Invariant 6: discount cannot exceed subtotal
check6 = discount_amount <= subtotal
print("Invariant 6: discount_amount <= subtotal -> " + ("PASS" if check6 else "FAIL"))
print("Discount amount: " + str(discount_amount))
total = subtotal - discount_amount
# Invariant 7: total is non-negative
check7 = total >= 0
print("Invariant 7: total >= 0 -> " + ("PASS" if check7 else "FAIL"))
print("Total: " + str(total))
print("All invariants hold. Function is correct for this input.")
return total
process_order([10, 20, 30], 0.1)Solution
def process_order(items, discount_rate):
check1 = isinstance(items, list)
print("Invariant 1: items is a list -> " + ("PASS" if check1 else "FAIL"))
check2 = all(item > 0 for item in items)
print("Invariant 2: all items are positive -> " + ("PASS" if check2 else "FAIL"))
check3 = 0 <= discount_rate <= 1
print("Invariant 3: discount is between 0 and 1 -> " + ("PASS" if check3 else "FAIL"))
subtotal = sum(items)
check4 = subtotal == sum(items)
print("Invariant 4: subtotal equals sum of items -> " + ("PASS" if check4 else "FAIL"))
print("Subtotal: " + str(subtotal))
discount_amount = subtotal * discount_rate
check5 = discount_amount >= 0
print("Invariant 5: discount_amount is non-negative -> " + ("PASS" if check5 else "FAIL"))
check6 = discount_amount <= subtotal
print("Invariant 6: discount_amount <= subtotal -> " + ("PASS" if check6 else "FAIL"))
print("Discount amount: " + str(discount_amount))
total = subtotal - discount_amount
check7 = total >= 0
print("Invariant 7: total >= 0 -> " + ("PASS" if check7 else "FAIL"))
print("Total: " + str(total))
print("All invariants hold. Function is correct for this input.")
return total
process_order([10, 20, 30], 0.1)
The rubber duck method, formalized:
The rubber duck technique asks you to explain what each line does. Invariant checks are the executable version of that explanation. Instead of saying "at this point, the subtotal should be positive," you write an assertion that proves it.
Seven invariants for this function:
- Input type: items is a list (not a string, not None).
- Input values: all items are positive (no negative prices).
- Input range: discount is between 0 (no discount) and 1 (100% off).
- Postcondition: subtotal equals the sum of all items.
- Postcondition: discount amount is non-negative.
- Business rule: discount never exceeds the subtotal.
- Business rule: the customer never owes a negative amount.
When to use this: When you cannot figure out why a function produces wrong results, add invariants after every line. The first one that fails tells you exactly where your mental model diverged from reality.
Expected Output
Invariant 1: items is a list -> PASS\nInvariant 2: all items are positive -> PASS\nInvariant 3: discount is between 0 and 1 -> PASS\nInvariant 4: subtotal equals sum of items -> PASS\nSubtotal: 60\nInvariant 5: discount_amount is non-negative -> PASS\nInvariant 6: discount_amount <= subtotal -> PASS\nDiscount amount: 6.0\nInvariant 7: total >= 0 -> PASS\nTotal: 54.0\nAll invariants hold. Function is correct for this input.Hints
Hint 1: Rubber duck debugging means explaining your code step by step, stating what should be true at each point. Assertions are the code version of this — they state invariants that must hold.
Hint 2: Write an assert (or check) after each operation that verifies the postcondition. If any fails, you have found exactly where your mental model diverges from reality.
Use the traceback module to programmatically inspect an exception. Extract the stack depth, every function name in the call chain, and identify the deepest frame where the error originated.
import traceback
import sys
def divide_numbers(a, b):
return a / b
def calculate_ratio(x, y):
return divide_numbers(x, y)
def process_data(values):
return calculate_ratio(values[0], values[1])
try:
process_data([10, 0])
except ZeroDivisionError:
exc_type, exc_value, exc_tb = sys.exc_info()
frames = traceback.extract_tb(exc_tb)
print("Exception type: " + exc_type.__name__)
print("Exception message: " + str(exc_value))
print("Stack depth: " + str(len(frames)))
for i, frame in enumerate(frames):
print("Frame " + str(i) + ": " + frame.name + " (line " + str(frame.lineno) + ")")
deepest = frames[-1]
print("Deepest function: " + deepest.name)
print("Error location: line " + str(deepest.lineno) + " in " + deepest.name)Solution
import traceback
import sys
def divide_numbers(a, b):
return a / b
def calculate_ratio(x, y):
return divide_numbers(x, y)
def process_data(values):
return calculate_ratio(values[0], values[1])
try:
process_data([10, 0])
except ZeroDivisionError:
exc_type, exc_value, exc_tb = sys.exc_info()
frames = traceback.extract_tb(exc_tb)
print("Exception type: " + exc_type.__name__)
print("Exception message: " + str(exc_value))
print("Stack depth: " + str(len(frames)))
for i, frame in enumerate(frames):
print("Frame " + str(i) + ": " + frame.name + " (line " + str(frame.lineno) + ")")
deepest = frames[-1]
print("Deepest function: " + deepest.name)
print("Error location: line " + str(deepest.lineno) + " in " + deepest.name)
Why use traceback programmatically:
- Automated error reporting: Send structured error data to monitoring systems (Sentry, Datadog) instead of raw text.
- Custom log formatting: Extract just the function names and line numbers you need.
- Error aggregation: Group errors by their deepest frame to find the most common crash sites.
Key API points:
sys.exc_info()returns(type, value, traceback)inside anexceptblock.traceback.extract_tb(tb)returns aStackSummary(list ofFrameSummaryobjects).- Each
FrameSummaryhas:.filename,.lineno,.name(function name),.line(source text). - The last frame in the list is always the deepest — where the exception was raised.
Expected Output
Exception type: ZeroDivisionError\nException message: division by zero\nStack depth: 3\nFrame 0: divide_numbers (line 2)\nFrame 1: calculate_ratio (line 5)\nFrame 2: process_data (line 8)\nDeepest function: divide_numbers\nError location: line 2 in divide_numbersHints
Hint 1: The `traceback` module provides `extract_tb()` which returns a list of `FrameSummary` objects from an exception traceback. Each has `.name`, `.lineno`, `.filename`, and `.line` attributes.
Hint 2: Use `sys.exc_info()` inside an except block to get the traceback object, then pass it to `traceback.extract_tb()`.
Hard
Build a minimal debugger using sys.settrace that traces function calls, line executions, and returns. This is how real debuggers like pdb work internally.
import sys
trace_output = []
def my_tracer(frame, event, arg):
# Only trace our own functions, not builtins
if frame.f_code.co_filename != __file__ and frame.f_code.co_filename != "<string>":
if "ipykernel" not in frame.f_code.co_filename and "runpy" not in frame.f_code.co_filename:
pass
name = frame.f_code.co_name
# Skip internal frames
if name.startswith("<") or name == "my_tracer":
return my_tracer
if event == "call":
trace_output.append("CALL: " + name + " (line " + str(frame.f_lineno) + ")")
elif event == "line":
trace_output.append(" LINE " + str(frame.f_lineno) + ": " + name)
elif event == "return":
trace_output.append("RETURN: " + name + " -> " + str(arg))
return my_tracer
def add(a, b):
result = a + b
return result
def multiply(a, b):
result = a * b
return result
# Enable tracing
sys.settrace(my_tracer)
r1 = add(3, 5)
r2 = multiply(3, 5)
sys.settrace(None) # Disable tracing
for line in trace_output:
print(line)
print("Results: " + str(r1) + ", " + str(r2))Solution
import sys
trace_output = []
def my_tracer(frame, event, arg):
name = frame.f_code.co_name
if name.startswith("<") or name == "my_tracer":
return my_tracer
if event == "call":
trace_output.append("CALL: " + name + " (line " + str(frame.f_lineno) + ")")
elif event == "line":
trace_output.append(" LINE " + str(frame.f_lineno) + ": " + name)
elif event == "return":
trace_output.append("RETURN: " + name + " -> " + str(arg))
return my_tracer
def add(a, b):
result = a + b
return result
def multiply(a, b):
result = a * b
return result
sys.settrace(my_tracer)
r1 = add(3, 5)
r2 = multiply(3, 5)
sys.settrace(None)
for line in trace_output:
print(line)
print("Results: " + str(r1) + ", " + str(r2))
How sys.settrace works internally:
- Registration:
sys.settrace(func)tells CPython to callfuncon every trace event for the current thread. - Events:
"call"(entering a function),"line"(about to execute a line),"return"(function is returning),"exception"(exception was raised). - The frame object: Contains
f_code(code object with function name, filename),f_lineno(current line number),f_locals(local variables),f_globals. - Return value: The trace function must return itself to keep tracing. Returning
Nonedisables tracing for that scope.
This is how pdb works. When you call breakpoint(), Python calls sys.settrace with pdb's trace function. The pdb trace function checks if the current line has a breakpoint and drops into the interactive prompt if so.
Performance warning: sys.settrace adds significant overhead because Python calls your function on every single line execution. Never leave it enabled in production.
Expected Output
CALL: add (line 8)\n LINE 9: add\n LINE 10: add\nRETURN: add -> 8\nCALL: multiply (line 12)\n LINE 13: multiply\n LINE 14: multiply\nRETURN: multiply -> 15\nResults: 8, 15Hints
Hint 1: `sys.settrace(func)` registers a callback that Python calls on every line execution, function call, return, and exception. The callback receives `(frame, event, arg)` where event is one of `"call"`, `"line"`, `"return"`, or `"exception"`.
Hint 2: The trace function must return itself (or a new trace function) to continue tracing inside the called function. Returning `None` stops tracing for that scope.
Simulate post-mortem debugging by capturing the full program state at the moment of a crash — every frame's local variables, the exact exception, and the call stack. This is what pdb.post_mortem() does internally.
import sys
import traceback
def process_records(records):
total = 0
for record in records:
total = total + record["score"] # Crashes on string score
return total
def main():
data = [
{"name": "Alice", "score": 95},
{"name": "Bob", "score": "80"}, # Bug: string instead of int
{"name": "Charlie", "score": 88},
]
return process_records(data)
# Capture the crash and perform post-mortem analysis
try:
main()
except TypeError:
exc_type, exc_value, exc_tb = sys.exc_info()
print("=== POST-MORTEM REPORT ===")
print("Exception: " + exc_type.__name__ + " - " + str(exc_value))
print("")
# Walk the traceback frames
print("Call Stack (most recent last):")
tb = exc_tb
frames_info = []
while tb is not None:
frame = tb.tb_frame
name = frame.f_code.co_name
if name not in ("main", "process_records"):
tb = tb.tb_next
continue
local_vars = {}
for k, v in frame.f_locals.items():
if not k.startswith("_"):
local_vars[k] = v
frames_info.append((name, local_vars))
tb = tb.tb_next
# Print in reverse so deepest is last
for name, local_vars in reversed(frames_info):
print(" Frame: " + name)
locals_str = ", ".join(str(k) + "=" + repr(v) for k, v in local_vars.items())
print(" Locals: " + locals_str)
print("")
print("Bug: record['score'] is string '80' at index 1")
print("Fix: validate or convert types before arithmetic")Solution
import sys
import traceback
def process_records(records):
total = 0
for record in records:
total = total + record["score"]
return total
def main():
data = [
{"name": "Alice", "score": 95},
{"name": "Bob", "score": "80"},
{"name": "Charlie", "score": 88},
]
return process_records(data)
try:
main()
except TypeError:
exc_type, exc_value, exc_tb = sys.exc_info()
print("=== POST-MORTEM REPORT ===")
print("Exception: " + exc_type.__name__ + " - " + str(exc_value))
print("")
print("Call Stack (most recent last):")
tb = exc_tb
frames_info = []
while tb is not None:
frame = tb.tb_frame
name = frame.f_code.co_name
if name not in ("main", "process_records"):
tb = tb.tb_next
continue
local_vars = {}
for k, v in frame.f_locals.items():
if not k.startswith("_"):
local_vars[k] = v
frames_info.append((name, local_vars))
tb = tb.tb_next
for name, local_vars in reversed(frames_info):
print(" Frame: " + name)
locals_str = ", ".join(str(k) + "=" + repr(v) for k, v in local_vars.items())
print(" Locals: " + locals_str)
print("")
print("Bug: record['score'] is string '80' at index 1")
print("Fix: validate or convert types before arithmetic")
How post-mortem debugging works:
- Capture the traceback:
sys.exc_info()returns the active exception's traceback object inside anexceptblock. - Walk the chain: Each traceback object (
tb) hastb.tb_frame(the frame at that level) andtb.tb_next(the next deeper level, orNone). - Inspect locals:
frame.f_localsis a dictionary of every local variable at the exact moment the frame was executing when the exception propagated through it.
What the post-mortem reveals:
- In
process_records,total=95(Alice's score was added successfully),recordis Bob's record with"score": "80"(a string). The crash happened ontotal + record["score"]becauseint + stris not supported. - In
main, we see the fulldatalist, confirming Bob's score is a string.
In production, use pdb.post_mortem() for interactive inspection or send the frame data to error monitoring services. The principle is the same: freeze the program state at the crash point and examine every variable.
Expected Output
=== POST-MORTEM REPORT ===\nException: TypeError - unsupported operand type(s) for +: 'int' and 'str'\n\nCall Stack (most recent last):\n Frame: process_records\n Locals: records=[{'name': 'Alice', 'score': 95}, {'name': 'Bob', 'score': '80'}, {'name': 'Charlie', 'score': 88}], total=95, record={'name': 'Bob', 'score': '80'}\n Frame: main\n Locals: data=[{'name': 'Alice', 'score': 95}, {'name': 'Bob', 'score': '80'}, {'name': 'Charlie', 'score': 88}]\n\nBug: record['score'] is string '80' at index 1\nFix: validate or convert types before arithmeticHints
Hint 1: Post-mortem debugging means inspecting the program state at the moment of the crash. In a real debugger, you would use `pdb.post_mortem()`. Here, simulate it by capturing `sys.exc_info()` and inspecting each frame in the traceback.
Hint 2: Walk the traceback chain with `tb.tb_next` to access each frame. Each frame has `f_locals` containing all local variables at the time of the crash.
Build an automated bug localizer that takes a pipeline of transformation functions, runs test inputs through each step, and automatically identifies which step produces incorrect output. This combines bisection debugging with automated testing.
def to_lowercase(s):
return s.lower()
def strip_spaces(s):
return s.strip()
def remove_vowels(s):
# Bug: should also replace spaces with hyphens but doesn't
vowels = "aeiouAEIOU"
return "".join(c for c in s if c not in vowels)
def replace_spaces(s):
return s.replace(" ", "-")
# The pipeline (remove_vowels should come AFTER replace_spaces)
pipeline = [
("to_lowercase", to_lowercase),
("strip_spaces", strip_spaces),
("remove_vowels", remove_vowels),
("replace_spaces", replace_spaces),
]
# Expected intermediate results for input " Hello World "
test_input = " Hello World "
expected_after = [
" hello world ", # after to_lowercase
"hello world", # after strip_spaces
"hll-wrld", # after remove_vowels (WRONG expectation given pipeline order)
"hll-wrld", # after replace_spaces
]
# Automated localizer
current = test_input
bug_found = False
for i, (name, func) in enumerate(pipeline):
current = func(current)
if current == expected_after[i]:
print("Testing transform_" + str(i) + " (" + name + "): OK")
else:
print("Testing transform_" + str(i) + " (" + name + "): MISMATCH")
print(" Input to this step: " + repr(expected_after[i - 1] if i > 0 else test_input))
print(" Output: " + repr(current))
print(" Expected after all steps: " + repr(expected_after[i]))
print("Bug localized to step " + str(i) + ": " + name)
print("Expected behavior: remove_vowels should also handle the hyphen replacement")
print("Actual behavior: remove_vowels only removes vowels, hyphen replacement is missing from pipeline")
bug_found = True
break
if not bug_found:
print("All steps produce expected output.")Solution
def to_lowercase(s):
return s.lower()
def strip_spaces(s):
return s.strip()
def remove_vowels(s):
vowels = "aeiouAEIOU"
return "".join(c for c in s if c not in vowels)
def replace_spaces(s):
return s.replace(" ", "-")
pipeline = [
("to_lowercase", to_lowercase),
("strip_spaces", strip_spaces),
("remove_vowels", remove_vowels),
("replace_spaces", replace_spaces),
]
test_input = " Hello World "
expected_after = [
" hello world ",
"hello world",
"hll-wrld",
"hll-wrld",
]
current = test_input
bug_found = False
for i, (name, func) in enumerate(pipeline):
current = func(current)
if current == expected_after[i]:
print("Testing transform_" + str(i) + " (" + name + "): OK")
else:
print("Testing transform_" + str(i) + " (" + name + "): MISMATCH")
print(" Input to this step: " + repr(expected_after[i - 1] if i > 0 else test_input))
print(" Output: " + repr(current))
print(" Expected after all steps: " + repr(expected_after[i]))
print("Bug localized to step " + str(i) + ": " + name)
print("Expected behavior: remove_vowels should also handle the hyphen replacement")
print("Actual behavior: remove_vowels only removes vowels, hyphen replacement is missing from pipeline")
bug_found = True
break
if not bug_found:
print("All steps produce expected output.")
What the automated localizer found:
The pipeline order is wrong. remove_vowels runs before replace_spaces, so when vowels are removed, the spaces are still there. The expected output "hll-wrld" assumes spaces were already replaced with hyphens, but they have not been yet.
Two possible fixes:
- Swap the order: run
replace_spacesbeforeremove_vowels. - Make
remove_vowelsalso replace spaces (but this violates single responsibility).
The automated localizer pattern:
- Define expected intermediate results for a known test input.
- Run each pipeline step and compare actual vs. expected.
- The first mismatch identifies the buggy step.
This is a formalized version of bisection debugging that can be run as part of a test suite. In production ML pipelines, this pattern catches feature engineering bugs that corrupt data silently.
Expected Output
Testing transform_0 (to_lowercase): OK\nTesting transform_1 (strip_spaces): OK\nTesting transform_2 (remove_vowels): MISMATCH\n Input to this step: 'hello world'\n Output: 'hll wrld'\n Expected after all steps: 'hll-wrld'\nBug localized to step 2: remove_vowels\nExpected behavior: remove_vowels should also handle the hyphen replacement\nActual behavior: remove_vowels only removes vowels, hyphen replacement is missing from pipelineHints
Hint 1: Build a pipeline runner that saves the intermediate result after each step. Then compare the actual intermediate results against expected values to find the first divergence.
Hint 2: The key insight is that you do not need to understand each function — you just need to know what the correct output should be at each stage and find where actual diverges from expected.
