10.1. Exceptions#
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, andfinallyCatch specific exception types and provide a meaningful response to each
Raise exceptions with
raiseand define custom exception classesApply print-statement debugging and
assertto isolate bugs systematicallyUse the
loggingmodule 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 |
|---|---|---|
|
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 |
|
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 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
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.
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:
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: 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.
(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 |
|
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:
Multiple except blocks: Handle each exception type differently. This is preferred as we want to catch specific exceptions first.
Single except with tuple: Handle multiple types the same way (e.g.,
except (ValueError, TypeError):)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.
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:
elseblock: Runs only if NO exception occurred in the try blockfinallyblock: 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.
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.
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 |
|---|---|---|
|
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 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 firstbasicConfig()call in a session 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")
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()#
|
|
|
|---|---|---|
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.
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