10.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 chapter you will be able to:

  • Distinguish between syntax errors, runtime errors, and 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 and assert to isolate bugs systematically

  • Use the logging module to record diagnostic messages at appropriate severity levels

10.1.1. Exception Handling#

Exception handling is runtime defense. It’s code that runs in production to handle conditions you expect might go wrong (try/except, raise). It’s about graceful failure when something bad actually happens. Think about exception handling this way: Exception handling answers this question: “what should my code do when things go wrong?”

When writing programs, errors are inevitable. Python distinguishes between

  • syntax errors (code that violates Python grammar rules) and

  • exceptions (errors detected during execution). Exception handling allows your program to respond gracefully to runtime errors instead of crashing.

Common Exception Types

Python has many built-in exception types. Here are the most common:

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)

When executing a program and exceptions happen, without exception handling:

  • Programs crash with cryptic error messages

  • Users have poor experience

  • Difficult to debug production issues

With exception handling, we can:

  • Programs can recover from errors

  • Provide user-friendly error messages

  • Log errors for debugging

10.1.1.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: The syntax of try-except is:

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

10.1.1.2. except Exception 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")
%%expect ZeroDivisionError
# Example 1: Simple try-except

try:
    result = 10 / 0
except ZeroDivisionError:
    print("Error: Cannot divide by zero!") ### program will not crash, error is handled
else:
    print(f"Result: {result}")
Error: Cannot divide by zero!

In this example, dividing by zero raises a ZeroDivisionError. The except clause catches it and prints a friendly message, so the program continues instead of crashing. Because an exception was raised, the else clause is skipped.

# Example 2: Practical example - safe file handling
def read_file_safely(filename):
    content = None
    try:
        with open(filename, "r") as file:
            content = file.read()
            print(f"File '{filename}' content:\n{content}")
    except FileNotFoundError:    ### may occur if the file does not exist
        print(f"Error: File '{filename}' not found!")
    except IOError as e:         ### may occur if there is an I/O error while reading the file
        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")  # This will trigger the FileNotFoundError
# read_file_safely("../../data/words.txt")  # This should read the current file successfully
# read_file_safely(__file__)  # This should read the current file successfully
Error: File 'non_existent_file.txt' not found!
File operation finished.
### 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

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

  • Use print statements to trace execution

  • Read error messages 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.

10.1.2.1. Understanding Errors#

When you write programs, things will go wrong. Learning to find and fix these problems is called debugging. Before you can handle errors gracefully, you need to understand what types of errors exist and how to find them.

Types of Errors:

Error Type

When It Occurs

Example

Syntax Error

Code violates Python grammar

Missing colon, unmatched quotes

Runtime Error (Exceptions)

Error during execution

Division by zero, file not found

Logic (Semantic) Error

Code runs but produces wrong results

Incorrect algorithm, wrong formula

When Python encounters a runtime error, it raises an exception. Without proper handling, exceptions cause programs to crash with error messages called tracebacks.

10.1.2.2. Reading Tracebacks#

When Python encounters a runtime error, it displays a traceback - an error report that shows where the error occurred. Learning to read tracebacks is essential for debugging.

The error message includes a traceback, which 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: you read it from bottom to top.

  • Bottom: The error type and message (e.g., “NameError: name ‘cat’ is not defined”)

  • Middle: The line where the error actually happened

  • Top: The chain of function calls that led there

# Example: Demonstrating traceback reading and debugging

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)
# Uncomment the line below to see the traceback
# cat_twice()

print("The above would produce a traceback like this:")
print("""
Traceback (most recent call last):
  File "debug_example.py", line 15, in <module>
    cat_twice()
  File "debug_example.py", line 11, in cat_twice
    print_twice(line1)
  File "debug_example.py", line 4, in print_twice
    print(cat)
NameError: name 'cat' is not defined
""")
The above would produce a traceback like this:

Traceback (most recent call last):
  File "debug_example.py", line 15, in <module>
    cat_twice()
  File "debug_example.py", line 11, in cat_twice
    print_twice(line1)
  File "debug_example.py", line 4, in print_twice
    print(cat)
NameError: name 'cat' is not defined
# Debugging steps:
print("Debugging steps:")
print("1. Read from bottom up: NameError on line 4 in print_twice()")
print("2. The undefined variable is 'cat'") 
print("3. Trace back: cat_twice() called print_twice() with line1")
print("4. Fix: Change 'cat' to 'value' in print_twice()")

print("\n" + "="*50 + "\n")
Debugging steps:
1. Read from bottom up: NameError on line 4 in print_twice()
2. The undefined variable is 'cat'
3. Trace back: cat_twice() called print_twice() with line1
4. Fix: Change 'cat' to 'value' in print_twice()

==================================================

10.1.2.3. Debugging Techniques#

Three practical techniques for tracking down bugs:

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.

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. Assertions are for developer checks — they can be disabled globally with python -O, so never use them for input validation in production code.

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
What is __name__?

Every Python type has a built-in attribute __name__ that holds the type’s readable name as a plain string of is a special built-in attribute that exists on classes, functions, and modules:

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'>".

For example, if you run Python with the -O flag: python -O myscript.py, every assert in your entire program is silently skipped and the checks never run. In summary:

  • 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)

3. Systematic bisection — When a bug is hard to pin down, divide and conquer: comment out half the suspect code (or add a print halfway through) and confirm whether the bug still appears. Keep halving until you isolate the exact line. This beats reading every line top-to-bottom.

Print-statement debugging

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("\n" + "="*50 + "\n")
=== 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

==================================================

Assertions

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)}"
        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
    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

Systematic bisection

Bisection debugging means divide and conquer: 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.

The example below has a three-stage pipeline with a real bug. The bisection steps show exactly 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.

    >>> analyze_temperatures([32, 212, 98.6, 77, 14])
    13.0
    """
    # Stage 1: convert to Celsius
    celsius = [(f - 32) * 5 / 9 for f in readings]

    # Stage 2: filter outliers
    filtered = [t for t in celsius if -10 <= t <= 45]

    # 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  = [(f - 32) * 5 / 9 for f in readings]
filtered = [t for t in celsius if -10 <= t <= 45]
print(f"\nDEBUG midpoint — celsius  = {[round(t, 1) for t in celsius]}")
print(f"DEBUG midpoint — filtered = {[round(t, 1) for t in 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
### 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

10.1.2.4. Type Hints, Static Checking, and Runtime Checking#

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.

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

10.1.3. Multiple Exception Handling#

You can handle multiple exception types in a single try/except block using several approaches:

  1. Multiple except blocks: Handle each exception type differently. This is preferred as we want to catch specific exceptions first.

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

  3. Generic except: Catch any exception (use cautiously because it hides bugs).

The better practice is to catch specific exceptions first, and only use except Exception as a last resort for truly unexpected errors.

Generic except refers to using a bare except or except Exception to catch any exception type:

Option 1: bare except (catches absolutely everything, including SystemExit, 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}")

The sample code below demonstrates how multiple exceptions may be handled.

# Example 4: 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

10.1.4. Else and Finally Blocks#

The try/except 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

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 5: 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
==================================================
### 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))   # 5.0  (then "Operation complete.")
print(safe_divide(5, 0))    # error message, then "Operation complete.", then None
Operation complete.
5.0
Error: cannot divide by zero
Operation complete.
None

10.1.5. Raising Custom Exceptions#

Python raises exceptions automatically (e.g., int("abc") # ValueError: invalid literal). raise ValueError, raise TypeError, raise RuntimeError. This covers 90% of real-world use. Students should know to pick the right built-in type rather than always reaching for Exception.

Sometimes, however, you need to raise your own exceptions (in addition to the ones provided by Python) to alert the caller that something is wrong. Note that you raise exceptions to push error handling up to the caller, rather than returning None or a magic value like -1.

You use the raise statement to raise your own exceptions.

Syntax:

raise ExceptionType("Error message")

For example, you may want to raise an exception with a specific alert, which is detection work. The caller of the function may catch that exception, which is handling.

As shown in the code below, the alert is caught by the caller of the function.

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 logic violations

  • Resource constraints

  • Invalid states

# Example 6: Raising custom exceptions

def validate_age(age):
    """Validate age input with custom 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

*this example below is kept here for you to visit after you learn OOP.

# Example 7: 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: 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

10.1.6. Logging#

The logging module provides a flexible framework for recording diagnostic messages during program execution. Unlike print(), logging lets you control which messages appear, where they go, and how much detail to include — without changing your code.

10.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 probably would want to save the logs to a file. This can be done in logging.basicConfig() using 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, this means only the first basicConfig() call in a session 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")

If you want to see the logging information in both the console and in the log, you may further configure logging.

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-05-10 02:28:20,721 - INFO - This message will go to both the file and the console.
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

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

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