5.1. Exceptions#
Learning goals: By the end of this section you will be able to:
Distinguish between syntax errors, runtime errors (exceptions), and logic (semantic) errors
Read and interpret Python tracebacks to locate the source of a bug
Handle exceptions gracefully with
try,except,else, andfinallyCatch specific exception types and provide a meaningful response to each
Raise exceptions with
raiseand define custom exception classesApply print-statement debugging,
assert, and bisection to isolate bugs systematicallyUse the
loggingmodule to record diagnostic messages at appropriate severity levels
5.1.1. Types of Errors#
When you write programs, things will go wrong. Before you can handle errors gracefully or fix them, you need to know what kinds of errors exist. Python programs fail in three different ways:
Error Type |
When It Occurs |
Example |
|---|---|---|
Syntax Error |
Code violates Python grammar; the program never starts |
Missing colon, unmatched quotes |
Runtime Error (Exception) |
Something goes wrong while the program is running |
Division by zero, file not found |
Logic (Semantic) Error |
Code runs but produces the wrong result |
Incorrect algorithm, wrong formula |
Python reports syntax errors before any code runs, and it cannot help you with logic errors at all, since the program does exactly what you wrote. Runtime errors are different: when one occurs, Python raises an exception. If nothing handles the exception, the program stops and prints an error report called a traceback.
Common Exception Types
Python has many built-in exception types. Here are the ones you will see most often:
Exception |
Description |
Example |
|---|---|---|
|
Invalid value for operation |
|
|
Wrong type for operation |
|
|
Division by zero |
|
|
Index out of range |
|
|
Dictionary key not found |
|
|
File doesn’t exist |
|
|
Attribute doesn’t exist |
|
|
Variable not defined |
|
The rest of this section follows the life of an exception: first you learn to read the traceback it produces, then to handle it, then to raise your own, and finally to track down the bugs behind it.
5.1.2. Reading Tracebacks#
When Python encounters a runtime error that nothing handles, it displays a traceback: an error report that shows where the error occurred. Learning to read tracebacks is essential for debugging.
A traceback shows:
Where the error occurred
Which lines were executed leading to the error
What type of error happened
The order of functions in the traceback matches the order of function calls, so the most useful information is at the end. Read it from bottom to top:
Bottom: The error type and message (e.g., “NameError: name ‘cat’ is not defined”)
Just above it: The line where the error actually happened
Top: The chain of function calls that led there
The two functions below contain a deliberate bug.
def print_twice(value):
"""Print a value twice. Contains a deliberate bug for demonstration."""
print(value)
print(cat) # Bug: 'cat' is not defined
def cat_twice():
"""Function that calls print_twice."""
line1 = "Bing tiddle "
line2 = "tiddle bang."
print_twice(line1)
print_twice(line2)
Calling cat_twice() produces the traceback below.
%%expect NameError
cat_twice()
Bing tiddle
---------------------------------------------------------------------------
NameError Traceback (most recent call last)
Cell In[5], line 1
----> 1 cat_twice()
Cell In[2], line 10, in cat_twice()
6 def cat_twice():
7 """Function that calls print_twice."""
8 line1 = "Bing tiddle "
9 line2 = "tiddle bang."
---> 10 print_twice(line1)
11 print_twice(line2)
Cell In[2], line 4, in print_twice(value)
1 def print_twice(value):
2 """Print a value twice. Contains a deliberate bug for demonstration."""
3 print(value)
----> 4 print(cat) # Bug: 'cat' is not defined
NameError: name 'cat' is not defined
Reading this traceback from the bottom up:
The last line says the error is a
NameError: the namecatis not defined.The line above it shows the error happened at
print(cat)insideprint_twice.Moving up,
print_twicewas called bycat_twice, which was called from the notebook cell.The fix is to change
cattovalueinprint_twice.
Notice that the first print(value) ran successfully (you can see its output above the traceback) before the bug was reached.
Now try it yourself. The functions below contain a different bug, and calling checkout raises a different kind of exception.
%%expect TypeError
def price_of(fruit, price_table):
return price_table[fruit]
def total_cost(cart, price_table):
total = "0"
for fruit in cart:
total += price_of(fruit, price_table)
return total
def checkout(cart, price_table):
return total_cost(cart, price_table)
prices = {"apple": 1.20, "banana": 0.50, "cherry": 3.00}
checkout(["apple", "banana"], prices)
---------------------------------------------------------------------------
TypeError Traceback (most recent call last)
Cell In[8], line 14
10 def checkout(cart, price_table):
11 return total_cost(cart, price_table)
12
13 prices = {"apple": 1.20, "banana": 0.50, "cherry": 3.00}
---> 14 checkout(["apple", "banana"], prices)
Cell In[8], line 11, in checkout(cart, price_table)
10 def checkout(cart, price_table):
---> 11 return total_cost(cart, price_table)
Cell In[8], line 7, in total_cost(cart, price_table)
4 def total_cost(cart, price_table):
5 total = "0"
6 for fruit in cart:
----> 7 total += price_of(fruit, price_table)
8 return total
TypeError: can only concatenate str (not "float") to str
### Exercise: Reading a Traceback
# The call to `checkout` above raises a TypeError.
# 1. Read the traceback from the bottom up. What is the error message?
# 2. Which function, and which line in it, raised the exception?
# 3. Trace the calls upward: which function called that one?
# 4. Find the bug and write corrected versions of the three functions below.
# Test:
# checkout(["apple", "banana"], prices) → 1.7
### Your code starts here.
### Your code ends here.
1.7
5.1.3. Handling Exceptions#
Exception handling is runtime defense: code that runs while the program is in use to deal with conditions you expect might go wrong, such as a missing file or bad user input. It answers the question “what should my code do when things go wrong?”
Without exception handling:
Programs crash with error messages that confuse users
Users have a poor experience
Problems in production are harder to diagnose
With exception handling, you can:
Recover from errors and keep the program running
Show user-friendly error messages
Record errors for later debugging
5.1.3.1. try-except#
The try-except statement lets you catch and respond to exceptions instead of letting them crash your program. You wrap the risky code in a try block; if an exception is raised, Python jumps immediately to the matching except block and executes it instead.
Syntax:
try:
# Code that might raise an exception
risky_code()
except ExceptionType:
# Handle the exception
handle_error()
A complete try statement can include up to four clauses, each serving a distinct role:
try: Block containing code that might raise an exceptionexcept: Block to handle specific exception type(s)else(optional): Block that executes if no exception occursfinally(optional): Block that always executes, regardless of exception
This part starts with try and except; else and finally are covered in else and finally.
# Example 1: Simple try-except
try:
result = 10 / 0
print(f"Result: {result}")
except ZeroDivisionError:
print("Error: Cannot divide by zero!") ### program does not crash; error is handled
Error: Cannot divide by zero!
In this example, dividing by zero raises a ZeroDivisionError. Python skips the rest of the try block (so Result: is never printed), runs the except clause, and the program continues instead of crashing.
5.1.3.2. Accessing the Exception Object (as e)#
Adding as e to an except clause binds the exception object to the variable e, so you can inspect it:
except ValueError as e:
print(e) # prints the error message
print(type(e)) # <class 'ValueError'>
e is just a conventional name; you could use any variable name (as err, as exc, etc.). Inside an f-string, {e} calls str(e), which produces the human-readable error message:
except FileNotFoundError as e:
print(f"Error: {e}") # e.g. "Error: [Errno 2] No such file or directory: 'x.txt'"
You can omit as e entirely when you don’t need the details:
except ZeroDivisionError:
print("Cannot divide by zero")
### Exercise: Catching Exceptions
# Write `safe_int(s)` that:
# 1. Tries to convert the string `s` to an integer with `int(s)`.
# 2. Returns the integer if conversion succeeds.
# 3. If `s` cannot be converted, catches the exception, prints a descriptive
# message, and returns None.
# Test:
# safe_int('42') → 42
# safe_int('3.14') → None (prints a message)
# safe_int('abc') → None (prints a message)
### Your code starts here.
### Your code ends here.
42
Cannot convert '3.14' to int
None
Cannot convert 'abc' to int
None
5.1.3.3. Handling Multiple Exceptions#
You can handle multiple exception types in a single try statement using several approaches:
Multiple
exceptblocks: Handle each exception type differently. This is the preferred approach, because it lets you respond to each specific problem.Single
exceptwith a tuple: Handle several types the same way (e.g.,except (ValueError, TypeError):).Generic
except: Catch any exception. Use this cautiously, because it can hide bugs.
The best practice is to catch specific exceptions first and use a generic except Exception only as a last resort for truly unexpected errors.
A generic except can be written in two ways:
Option 1: bare except (catches absolutely everything, including SystemExit and KeyboardInterrupt)
try:
risky_code()
except:
print("Something went wrong")
Option 2: except Exception (preferred; catches all normal errors but not system exits)
try:
risky_code()
except Exception as e:
print(f"Error: {type(e).__name__}: {e}")
What is __name__?
Every class has a special built-in attribute __name__ that holds its name as a plain string. (Functions and modules have one too.) Because every type is a class, type(x).__name__ gives you the readable name of a value’s type:
type(42).__name__ # 'int'
type("hello").__name__ # 'str'
type(3.14).__name__ # 'float'
type(None).__name__ # 'NoneType'
In exception handling you often use it to produce a readable error message without hard-coding a type name:
if not isinstance(age, (int, float)):
raise TypeError(f"Age must be a number, got {type(age).__name__}")
If someone passes "twenty", the message reads "Age must be a number, got str" rather than "Age must be a number, got <class 'str'>".
The example below uses multiple except blocks. It also uses raise ValueError(...) to signal an unknown operation; raise is covered in Raising Exceptions, and for now it is enough to know that it triggers an exception on purpose.
# Example 2: Multiple exception handling approaches
def calculate_from_strings(num1_str, num2_str, operation):
"""Perform calculations with comprehensive error handling."""
try:
# Convert strings to numbers
num1 = float(num1_str)
num2 = float(num2_str)
# Perform the requested operation
if operation == 'add':
result = num1 + num2
elif operation == 'subtract':
result = num1 - num2
elif operation == 'multiply':
result = num1 * num2
elif operation == 'divide':
result = num1 / num2
else:
raise ValueError(f"Unknown operation: {operation}")
return result
except ValueError as e:
print(f"ValueError: {e}")
return None
except ZeroDivisionError:
print("ZeroDivisionError: Division by zero is not allowed")
return None
except Exception as e: ### general catch-all for unexpected exceptions
print(f"Unexpected error: {type(e).__name__}: {e}")
return None
# Test different scenarios
test_cases = [
("10", "5", "add"), # Valid
("10", "abc", "add"), # ValueError (invalid number)
("10", "0", "divide"), # ZeroDivisionError
("10", "5", "power"), # ValueError (unknown operation)
]
print("Testing calculator with error handling:")
for num1, num2, op in test_cases:
result = calculate_from_strings(num1, num2, op)
if result is not None:
print(f"{num1} {op} {num2} = {result}")
print("-" * 40)
Testing calculator with error handling:
10 add 5 = 15.0
----------------------------------------
ValueError: could not convert string to float: 'abc'
----------------------------------------
ZeroDivisionError: Division by zero is not allowed
----------------------------------------
ValueError: Unknown operation: power
----------------------------------------
### Exercise: Multiple Exception Types
# Write `safe_index(lst, i)` that:
# 1. Returns lst[i] if the access succeeds.
# 2. Catches IndexError (index out of range): prints a message and returns None.
# 3. Catches TypeError (non-integer index): prints a message and returns None.
# Test:
# safe_index([10, 20, 30], 1) → 20
# safe_index([10, 20, 30], 9) → None (IndexError)
# safe_index([10, 20, 30], 'a') → None (TypeError)
### Your code starts here.
### Your code ends here.
20
Index 9 is out of range for a list of length 3
None
Index must be an integer, got str
None
5.1.3.4. else and finally#
The try statement can include else and finally blocks for additional control:
elseblock: Runs only if NO exception occurred in thetryblockfinallyblock: ALWAYS runs, whether an exception occurred or not
Putting success-only code in else (rather than at the end of try) keeps the try block small, so you don’t accidentally catch exceptions from code you didn’t mean to protect. finally is the place for cleanup, such as closing files or connections.
Complete Syntax:
try:
# Code that might raise an exception
risky_code()
except SpecificException:
# Handle specific exception
handle_error()
else:
# Code that runs only if no exception occurred
success_code()
finally:
# Code that always runs (cleanup)
cleanup_code()
# Example 3: Using else and finally blocks
def file_processor(filename):
"""Demonstrate else and finally blocks."""
print(f"\nProcessing file: {filename}")
try:
# Simulate file processing
if filename == "missing.txt":
raise FileNotFoundError("File does not exist")
elif filename == "corrupted.txt":
raise ValueError("File is corrupted")
else:
print(f"Successfully opened {filename}")
data = f"Contents of {filename}"
except FileNotFoundError as e:
print(f"File error: {e}")
data = None
except ValueError as e:
print(f"Data error: {e}")
data = None
else:
# This runs only if no exception occurred
print("File processed successfully!")
print(f"Data length: {len(data)}")
finally:
# This always runs
print("Cleaning up resources...")
print("File processor finished")
return data
# Test different scenarios
test_files = ["document.txt", "missing.txt", "corrupted.txt"]
for filename in test_files:
result = file_processor(filename)
print(f"Returned: {result}")
print("=" * 50)
Processing file: document.txt
Successfully opened document.txt
File processed successfully!
Data length: 24
Cleaning up resources...
File processor finished
Returned: Contents of document.txt
==================================================
Processing file: missing.txt
File error: File does not exist
Cleaning up resources...
File processor finished
Returned: None
==================================================
Processing file: corrupted.txt
Data error: File is corrupted
Cleaning up resources...
File processor finished
Returned: None
==================================================
Example 3 simulates its errors. The next example puts all four clauses together on a real file operation.
Its second except clause catches OSError, the built-in exception Python raises for file and operating-system problems, such as a file you do not have permission to read. FileNotFoundError is a more specific kind of OSError, so its clause comes first. In older code you may see except IOError; IOError is simply an older name for OSError.
# Example 4: Practical example - safe file handling
def read_file_safely(filename):
content = None
try:
# `with open(...)` opens the file and closes it automatically when the block ends
with open(filename, "r") as file:
content = file.read()
print(f"File '{filename}' content:\n{content}")
except FileNotFoundError: ### occurs if the file does not exist
print(f"Error: File '{filename}' not found!")
except OSError as e: ### any other file problem (IOError is an older name for OSError)
print(f"Error reading file: {e}")
else:
print("File reading completed successfully.")
finally:
print("File operation finished.\n")
return content
read_file_safely("non_existent_file.txt") # triggers FileNotFoundError
# read_file_safely("../../data/words.txt") # uncomment to read an existing file
Error: File 'non_existent_file.txt' not found!
File operation finished.
### Exercise: Safe Division with Cleanup
# Write `safe_divide(a, b)` that:
# 1. Returns the result of `a / b`.
# 2. If `b` is zero, prints "Error: cannot divide by zero" and returns None.
# 3. Always prints "Operation complete." in a `finally` block, regardless of outcome.
### Your code starts here.
### Your code ends here.
Operation complete.
5.0
Error: cannot divide by zero
Operation complete.
None
Notice that Operation complete. appears before 5.0. The finally block runs when the function finishes, and only then does print() receive the returned value.
5.1.4. Raising Exceptions#
So far, Python has raised the exceptions for you: int("abc") raises a ValueError automatically. You can also raise exceptions yourself with the raise statement, to tell the caller that something is wrong.
Syntax:
raise ExceptionType("Error message")
Raising an exception pushes the decision about what to do up to the caller, which is usually better than returning None or a “magic value” like -1 that the caller might forget to check. Raising is detection work; catching the exception in the caller is handling.
In most cases, you should raise one of Python’s built-in types (ValueError, TypeError, RuntimeError, and so on) rather than the generic Exception. Pick the type that describes the problem: TypeError for a value of the wrong type, ValueError for a value of the right type but an invalid content.
In the code below, divide detects the problem and the caller handles it.
def divide(a, b):
if b == 0:
raise ValueError("b cannot be zero")
return a / b
try:
result = divide(10, 0)
except ValueError as e:
print(e)
b cannot be zero
Common scenarios for raising exceptions:
Input validation fails
Business rules are violated
Resources are unavailable
An object is in an invalid state
# Example 5: Raising exceptions for input validation
def validate_age(age):
"""Validate age input by raising built-in exceptions."""
if not isinstance(age, (int, float)):
raise TypeError(f"Age must be a number, got {type(age).__name__}")
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age cannot be greater than 150")
print(f"Valid age: {age}")
return age
# Test age validation
test_ages = [25, -5, 200, "twenty", 45.5, None]
for age in test_ages:
try:
validate_age(age)
except (TypeError, ValueError) as e:
print(f"Validation failed for {age}: {e}")
Valid age: 25
Validation failed for -5: Age cannot be negative
Validation failed for 200: Age cannot be greater than 150
Validation failed for twenty: Age must be a number, got str
Valid age: 45.5
Validation failed for None: Age must be a number, got NoneType
### Exercise: Raising Exceptions
# Write `square_root(x)` that:
# 1. Raises a `TypeError` with a descriptive message if x is not a number (int or float).
# 2. Raises a `ValueError` with a descriptive message if x < 0.
# 3. Otherwise returns x ** 0.5.
# Test:
# square_root(9) → 3.0
# square_root(-4) → raises ValueError
# square_root("nine") → raises TypeError
### Your code starts here.
### Your code ends here.
3.0
ValueError: Cannot take square root of negative number: -4
TypeError: Expected a number, got str
5.1.4.1. Type Hints Are Not Runtime Checks#
Type hints describe what a function expects, but they are not runtime validation. If you annotate a parameter as int, Python will still let someone pass a string unless your code checks for it and raises an exception.
def double(n: int) -> int:
return n * 2
print(double("ha")) # Python allows this; the result is "haha"
There are three different ideas that are easy to confuse:
Technique |
When it runs |
What it does |
|---|---|---|
Type hints |
Not enforced by Python itself |
Documents expected types |
|
Before the program runs |
Reports likely type mistakes |
Runtime checks |
While the program runs |
Raises errors or handles bad values |
A static checker such as mypy can warn that a call probably violates the function contract:
mypy my_program.py
Runtime checks are regular Python code: an if test followed by raise. Use them when invalid input must be rejected while the program is running.
def average(numbers: list[float]) -> float:
if not isinstance(numbers, list):
raise TypeError(f"numbers must be a list, got {type(numbers).__name__}")
if len(numbers) == 0:
raise ValueError("numbers must not be empty")
return sum(numbers) / len(numbers)
print(average([10.0, 20.0, 30.0]))
20.0
Use type hints to make intent clear. Use tests to verify behavior. Use runtime checks and exceptions only when the program needs to defend itself against invalid data while it is running.
5.1.4.2. Custom Exception Classes#
When no built-in type describes your problem well, you can define your own exception type by creating a class that inherits from Exception. Callers can then catch your specific error separately from everything else.
Note
The example below uses classes, which are covered in the OOP chapter. You can skim it now and come back to it after that chapter.
# Example 6: Custom exception classes
class InsufficientFundsError(Exception):
"""Raised when a withdrawal exceeds the current balance."""
pass
class BankAccount:
"""Simple bank account with exception handling."""
def __init__(self, initial_balance=0):
if initial_balance < 0:
raise ValueError("Initial balance cannot be negative")
self.balance = initial_balance
def deposit(self, amount):
if amount <= 0:
raise ValueError("Deposit amount must be positive")
self.balance += amount
print(f"Deposited ${amount}. New balance: ${self.balance}")
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self.balance:
raise InsufficientFundsError(
f"Insufficient funds: balance=${self.balance}, requested=${amount}"
)
self.balance -= amount
print(f"Withdrew ${amount}. New balance: ${self.balance}")
def get_balance(self):
return self.balance
# Test the bank account
print("Testing bank account:")
try:
account = BankAccount(100)
account.deposit(50)
account.withdraw(30)
account.withdraw(200) # This will raise InsufficientFundsError
except ValueError as e:
print(f"Input error: {e}")
except InsufficientFundsError as e:
print(f"Banking error: {e}")
Testing bank account:
Deposited $50. New balance: $150
Withdrew $30. New balance: $120
Banking error: Insufficient funds: balance=$120, requested=$200
### Exercise: Custom Exception Class
# 1. Define an exception class `OutOfStockError` that inherits from `Exception`.
# The body needs only `pass`, like `InsufficientFundsError` above.
# 2. Write a function `buy_fruit(fruit, inventory)` that:
# - raises `OutOfStockError` with a descriptive message if inventory[fruit] is 0
# - otherwise subtracts 1 from inventory[fruit] and returns the new count
# 3. Call `buy_fruit` inside `try` and catch `OutOfStockError` by name.
# Test:
# inventory = {"apple": 2, "banana": 0, "cherry": 5}
# buy_fruit("apple", inventory) → 1
# buy_fruit("banana", inventory) → raises OutOfStockError
### Your code starts here.
### Your code ends here.
1
OutOfStockError: Sorry, banana is out of stock
5.1.5. Debugging#
Exceptions tell you that something went wrong. Finding out why is debugging. Debugging can be frustrating, but it is also challenging, interesting, and sometimes even fun. It is one of the most important skills you can learn.
Debugging is like detective work: You are given clues, and you have to infer the events that led to the results you see.
Debugging is like experimental science: Once you have an idea about what is going wrong, you modify your program and try again. If your hypothesis was correct, you can predict the result of the modification. If your hypothesis was wrong, you have to come up with a new one.
Key debugging principles:
Start with a working program and make small modifications
Test frequently; don’t write too much code before testing
Read error messages and tracebacks carefully
Think systematically about what could be wrong
If you find yourself spending a lot of time debugging, that’s often a sign you’re writing too much code before testing. Taking smaller steps can help you move more quickly.
The rest of this part covers three practical techniques: print-statement debugging, assertions, and systematic bisection.
5.1.5.1. Print-Statement Debugging#
Insert temporary print() calls to inspect variable values and confirm which lines of code are actually running. Prefix them with DEBUG: so they are easy to find and remove later. This is the simplest technique and works well for most everyday bugs.
def debug_with_print_statements():
"""Demonstrate print statement debugging."""
print("=== Print Statement Debugging ===")
def calculate_average(numbers):
print(f"DEBUG: Input numbers = {numbers}")
print(f"DEBUG: Length = {len(numbers)}")
total = 0
for i, num in enumerate(numbers):
total += num
print(f"DEBUG: Step {i+1}: added {num}, total now = {total}")
average = total / len(numbers)
print(f"DEBUG: Final average = {average}")
return average
# Test with example data
test_numbers = [10, 20, 30, 40]
result = calculate_average(test_numbers)
print(f"Result: {result}")
debug_with_print_statements()
=== Print Statement Debugging ===
DEBUG: Input numbers = [10, 20, 30, 40]
DEBUG: Length = 4
DEBUG: Step 1: added 10, total now = 10
DEBUG: Step 2: added 20, total now = 30
DEBUG: Step 3: added 30, total now = 60
DEBUG: Step 4: added 40, total now = 100
DEBUG: Final average = 25.0
Result: 25.0
5.1.5.2. Assertions#
Use assert to document assumptions your code makes (e.g., “this argument must be a non-negative integer”). If the assumption is ever violated, Python raises an AssertionError immediately at the offending line rather than silently producing a wrong result later.
The syntax of assert is:
assert condition
assert condition, "message shown if condition is False"
For example,
%%expect AssertionError
items = ["Alarm Clock", "Backpack", "Candle", "Doll"]
n = 42.0
assert 2 + 2 == 4 # passes silently
assert len(items) > 0 # passes if list is non-empty
assert isinstance(n, int), f"Expected int, got {type(n).__name__}" ### fails w/ message
AssertionError: Expected int, got float
Assertions are for developer checks, and they can be switched off. If you run Python with the -O flag (python -O myscript.py), every assert in your program is silently skipped. For that reason, never use assert to validate user input or other data your program must reject. Compare assert with raise:
assert: “this should never be False if my code is correct” (catching your own bugs during development)raise: “this input is invalid and I need to handle it” (defending against bad data at runtime)
The example below uses assertions to check a function’s assumptions and an intermediate result.
def debug_with_assertions():
"""Demonstrate assertion debugging."""
print("=== Assertion Debugging ===")
def factorial(n):
# Assertions help catch problems early
assert isinstance(n, int), f"Expected int, got {type(n).__name__}"
assert n >= 0, f"Expected non-negative number, got {n}"
print(f"Computing factorial of {n}")
if n == 0 or n == 1:
return 1
result = 1
for i in range(2, n + 1):
result *= i
# Assertion to check intermediate results
assert result > 0, f"Unexpected negative result at step {i}"
return result
# Test cases
### add -10 to test_cases to see an assertion fail
test_cases = [5, 0, 1]
for test in test_cases:
try:
result = factorial(test)
print(f"factorial({test}) = {result}")
except AssertionError as e:
print(f"Assertion failed for factorial({test}): {e}")
debug_with_assertions()
=== Assertion Debugging ===
Computing factorial of 5
factorial(5) = 120
Computing factorial of 0
factorial(0) = 1
Computing factorial of 1
factorial(1) = 1
### Exercise: Using Assertions
# Add assert statements to `validate_triangle(a, b, c)` that check:
# 1. All three sides are positive (> 0). Use a single assert for all three.
# 2. Each side is less than the sum of the other two (triangle inequality).
# Each assert should include a descriptive message.
# Test:
# validate_triangle(3, 4, 5) → passes silently
# validate_triangle(0, 4, 5) → AssertionError (side not positive)
# validate_triangle(1, 2, 10) → AssertionError (violates triangle inequality)
### Your code starts here.
### Your code ends here.
(3, 4, 5) is a valid triangle
AssertionError: All sides must be positive
AssertionError: Side c=10 violates the triangle inequality
5.1.5.3. Systematic Bisection#
When a bug is hard to pin down, divide and conquer. Bisection debugging works like this: when a multi-step function produces the wrong answer and you don’t know which step is at fault, add a checkpoint halfway through and check whether the intermediate data is already wrong at that point. If it is, the bug is in the first half; if not, it’s in the second half. Keep halving until you isolate the exact line. This is much faster than reading every line from top to bottom.
The example below has a three-stage pipeline with a real bug. The bisection steps show how to narrow it down without reading every line.
def analyze_temperatures(readings):
"""
Convert Fahrenheit readings to Celsius, discard outliers
(below -10 °C or above 45 °C), then return the average.
For [32, 212, 98.6, 77, 14] the result should be 13.0.
"""
# Stage 1: convert to Celsius
celsius = []
for f in readings:
celsius.append((f - 32) * 5 / 9)
# Stage 2: filter outliers
filtered = []
for t in celsius:
if -10 <= t <= 45:
filtered.append(t)
# Stage 3: compute average ← bug is here
average = sum(filtered) / len(celsius) # wrong: divides by original length
return average
readings = [32, 212, 98.6, 77, 14]
print(f"Result: {analyze_temperatures(readings):.1f}") # prints 10.4
print(f"Expected: 13.0")
# --- Bisection step 1: checkpoint after Stage 2 (the midpoint of three stages) ---
celsius = []
for f in readings:
celsius.append((f - 32) * 5 / 9)
filtered = []
for t in celsius:
if -10 <= t <= 45:
filtered.append(t)
print(f"\nDEBUG midpoint: celsius = {celsius}")
print(f"DEBUG midpoint: filtered = {filtered}")
# celsius = [0.0, 100.0, 37.0, 25.0, -10.0]
# filtered = [0.0, 37.0, 25.0, -10.0] ← correct (100.0 filtered out, 4 values remain)
# Stages 1 and 2 are fine; the bug must be in Stage 3.
# --- Bisection step 2: inspect Stage 3 ---
print(f"\nDEBUG Stage 3: sum = {sum(filtered):.1f}, dividing by len(celsius) = {len(celsius)}")
# sum = 52.0, dividing by 5 ← wrong! should be len(filtered) = 4
print("Bug found: 'len(celsius)' should be 'len(filtered)'\n")
# --- Fix ---
average = sum(filtered) / len(filtered)
print(f"Fixed result: {average:.1f}") # 13.0
Result: 10.4
Expected: 13.0
DEBUG midpoint: celsius = [0.0, 100.0, 37.0, 25.0, -10.0]
DEBUG midpoint: filtered = [0.0, 37.0, 25.0, -10.0]
DEBUG Stage 3: sum = 52.0, dividing by len(celsius) = 5
Bug found: 'len(celsius)' should be 'len(filtered)'
Fixed result: 13.0
5.1.6. Logging#
Print-statement debugging works, but the print() calls have to be deleted when you are done, and they cannot be turned on again later. The logging module solves this. It provides a flexible framework for recording diagnostic messages during program execution: you control which messages appear, where they go, and how much detail to include, without changing your code.
5.1.6.1. Log Levels#
Level |
Value |
When to use |
|---|---|---|
|
10 |
Detailed diagnostic info (development only) |
|
20 |
Confirmation that things are working as expected |
|
30 |
Something unexpected; program still running |
|
40 |
A more serious problem; something failed |
|
50 |
A severe error; program may not continue |
The default level is WARNING: only WARNING, ERROR, and CRITICAL messages appear unless you configure a lower threshold.
When configuring logging, you will usually want to save the logs to a file. You can do this in logging.basicConfig() with the filename= parameter.
Note:
basicConfig()in notebooks.logging.basicConfig()is a no-op (short for “no operation”) after the first call in a session: if the root logger already has handlers attached, the call is silently ignored. In a notebook, where all cells share the same kernel state, only the firstbasicConfig()call takes effect. The code cells below passforce=TruetobasicConfig(), which removes and closes any existing handlers before applying the new configuration, so each cell behaves correctly regardless of run order.
import logging
# Configure logging to save to a file
# force=True removes and closes any existing handlers so this cell works on re-runs
logging.basicConfig(filename='app.log', level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
force=True)
logging.info("This is an info message saved to app.log")
logging.warning("This is a warning message")
Because this configuration writes only to app.log, nothing appears below the cell. To see log messages in both the console and the file, give basicConfig() two handlers:
import logging
# Define the handlers
file_handler = logging.FileHandler('app.log')
console_handler = logging.StreamHandler()
# Configure the root logger
# force=True closes and removes any existing handlers before applying this configuration
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s - %(levelname)s - %(message)s',
handlers=[file_handler, console_handler],
force=True
)
logging.info("This message will go to both the file and the console.")
2026-10-12 05:16:28,370 - INFO - This message will go to both the file and the console.
With the level set to DEBUG, messages at every level are shown:
import logging
# Show ALL levels (DEBUG and above)
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s', force=True)
logging.debug('Reading config file') # detailed diagnostics
logging.info('Server started on port 8080') # normal operations
logging.warning('Disk space is low') # something unexpected
logging.error('Failed to connect to database') # something failed
logging.critical('System is shutting down') # severe error
DEBUG: Reading config file
INFO: Server started on port 8080
WARNING: Disk space is low
ERROR: Failed to connect to database
CRITICAL: System is shutting down
5.1.6.2. Logging vs print()#
|
|
|
|---|---|---|
Severity levels |
No |
Yes |
Easy to silence |
No (must delete/comment) |
Yes (set level higher) |
Timestamps |
No |
Yes (with format string) |
Write to file |
No |
Yes |
Production-ready |
No |
Yes |
Best practice: use print() for user-facing output and logging for diagnostic messages during development and production monitoring. Here is the division example from Raising Exceptions, instrumented with logging instead of print():
import logging
logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s', force=True)
def divide_with_logging(a, b):
logging.debug(f'divide_with_logging called with a={a}, b={b}')
if b == 0:
logging.error('Division by zero attempted')
raise ValueError('Cannot divide by zero')
result = a / b
logging.info(f'Result: {result}')
return result
print(divide_with_logging(10, 2)) # 5.0
DEBUG: divide_with_logging called with a=10, b=2
INFO: Result: 5.0
5.0
### Exercise: Add Logging
# Write `safe_sqrt(x)` that uses `logging` instead of `print`:
# - DEBUG: log the input value before computing ('safe_sqrt called with x={x}')
# - WARNING: log a warning if x is negative
# - ERROR: raise ValueError if x is negative
# - INFO: log the result before returning
# Test: safe_sqrt(25) → 5.0; safe_sqrt(0) → 0.0; safe_sqrt(-4) → ValueError.
import math
### Your code starts here.
### Your code ends here.
DEBUG: safe_sqrt called with x=25
INFO: Result: 5.0
DEBUG: safe_sqrt called with x=0
INFO: Result: 0.0
DEBUG: safe_sqrt called with x=-4
WARNING: Negative input: -4
ERROR: Cannot take square root of a negative number
5.0
0.0
Caught: Cannot take square root of negative number: -4