5.1. Exceptions#

Hide code cell source

import sys
from pathlib import Path

current = Path.cwd()
for parent in [current, *current.parents]:
    if (parent / '_config.yml').exists():
        project_root = parent  # ← Add project root, not chapters
        break
else:
    project_root = Path.cwd().parent.parent

sys.path.insert(0, str(project_root))

from shared import thinkpython, diagram, jupyturtle
from shared.download import download

# Register as top-level modules so direct imports work in subsequent cells
sys.modules['thinkpython'] = thinkpython
sys.modules['diagram'] = diagram
sys.modules['jupyturtle'] = jupyturtle

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, and finally

  • Catch specific exception types and provide a meaningful response to each

  • Raise exceptions with raise and define custom exception classes

  • Apply print-statement debugging, assert, and bisection to isolate bugs systematically

  • Use the logging module 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

ValueError

Invalid value for operation

int("hello")

TypeError

Wrong type for operation

"5" + 5

ZeroDivisionError

Division by zero

10 / 0

IndexError

Index out of range

[1, 2][5]

KeyError

Dictionary key not found

{}["missing"]

FileNotFoundError

File doesn’t exist

open("nonexistent.txt")

AttributeError

Attribute doesn’t exist

"text".nonexistent()

NameError

Variable not defined

print(undefined_var)

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:

  1. Where the error occurred

  2. Which lines were executed leading to the error

  3. 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:

  1. The last line says the error is a NameError: the name cat is not defined.

  2. The line above it shows the error happened at print(cat) inside print_twice.

  3. Moving up, print_twice was called by cat_twice, which was called from the notebook cell.

  4. The fix is to change cat to value in print_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.

Hide code cell source

### Solution

# The last line of the traceback says: can only concatenate str (not "float") to str.
# The line above it points to `total += price_of(fruit, price_table)` in total_cost,
# which checkout called. total starts as the string "0" instead of the number 0,
# so adding a float price to it fails.

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}
print(checkout(["apple", "banana"], prices))   # 1.7
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 exception

  • except: Block to handle specific exception type(s)

  • else (optional): Block that executes if no exception occurs

  • finally (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.

Hide code cell source

### Solution

def safe_int(s):
    try:
        return int(s)
    except ValueError:
        print(f"Cannot convert {s!r} to int")
        return None

print(safe_int('42'))    # 42
print(safe_int('3.14'))  # message, then None
print(safe_int('abc'))   # message, then None
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:

  1. Multiple except blocks: Handle each exception type differently. This is the preferred approach, because it lets you respond to each specific problem.

  2. Single except with a tuple: Handle several types the same way (e.g., except (ValueError, TypeError):).

  3. 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.

Hide code cell source

### Solution

def safe_index(lst, i):
    try:
        return lst[i]
    except IndexError:
        print(f"Index {i} is out of range for a list of length {len(lst)}")
        return None
    except TypeError:
        print(f"Index must be an integer, got {type(i).__name__}")
        return None

print(safe_index([10, 20, 30], 1))    # 20
print(safe_index([10, 20, 30], 9))    # message, then None
print(safe_index([10, 20, 30], 'a'))  # message, then None
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:

  • else block: Runs only if NO exception occurred in the try block

  • finally block: 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.

Hide code cell source

### Solution

def safe_divide(a, b):
    try:
        result = a / b
    except ZeroDivisionError:
        print("Error: cannot divide by zero")
        result = None
    finally:
        print("Operation complete.")
    return result

print(safe_divide(10, 2))   # "Operation complete." prints first, then 5.0
print(safe_divide(5, 0))    # error message, "Operation complete.", then None
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.

Hide code cell source

### Solution

def square_root(x):
    if not isinstance(x, (int, float)):
        raise TypeError(f"Expected a number, got {type(x).__name__}")
    if x < 0:
        raise ValueError(f"Cannot take square root of negative number: {x}")
    return x ** 0.5

print(square_root(9))        # 3.0

try:
    square_root(-4)
except ValueError as e:
    print(f"ValueError: {e}")

try:
    square_root("nine")
except TypeError as e:
    print(f"TypeError: {e}")
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

mypy or another static checker

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.

Hide code cell source

### Solution

class OutOfStockError(Exception):
    pass

def buy_fruit(fruit, inventory):
    if inventory[fruit] == 0:
        raise OutOfStockError(f"Sorry, {fruit} is out of stock")
    inventory[fruit] -= 1
    return inventory[fruit]

inventory = {"apple": 2, "banana": 0, "cherry": 5}
print(buy_fruit("apple", inventory))   # 1

try:
    buy_fruit("banana", inventory)
except OutOfStockError as e:
    print(f"OutOfStockError: {e}")
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.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.

Hide code cell source

### Solution

def validate_triangle(a, b, c):
    assert a > 0 and b > 0 and c > 0, "All sides must be positive"
    assert a < b + c, f"Side a={a} violates the triangle inequality"
    assert b < a + c, f"Side b={b} violates the triangle inequality"
    assert c < a + b, f"Side c={c} violates the triangle inequality"

validate_triangle(3, 4, 5)   # passes silently
print("(3, 4, 5) is a valid triangle")

try:
    validate_triangle(0, 4, 5)
except AssertionError as e:
    print(f"AssertionError: {e}")

try:
    validate_triangle(1, 2, 10)
except AssertionError as e:
    print(f"AssertionError: {e}")
(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

DEBUG

10

Detailed diagnostic info (development only)

INFO

20

Confirmation that things are working as expected

WARNING

30

Something unexpected; program still running

ERROR

40

A more serious problem; something failed

CRITICAL

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 first basicConfig() call takes effect. The code cells below pass force=True to basicConfig(), 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()#

print()

logging

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.

Hide code cell source

### Solution

import math
import logging

logging.basicConfig(level=logging.DEBUG, format='%(levelname)s: %(message)s', force=True)

def safe_sqrt(x):
    logging.debug(f'safe_sqrt called with x={x}')
    if x < 0:
        logging.warning(f'Negative input: {x}')
        logging.error('Cannot take square root of a negative number')
        raise ValueError(f'Cannot take square root of negative number: {x}')
    result = math.sqrt(x)
    logging.info(f'Result: {result}')
    return result

print(safe_sqrt(25))   # 5.0
print(safe_sqrt(0))    # 0.0

try:
    safe_sqrt(-4)      # raises ValueError
except ValueError as e:
    print(f'Caught: {e}')
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