Error Handling and Debugging
Introduction
Errors are inevitable in programming. Good programmers don't just write code that works - they write code that handles failures gracefully and can be debugged efficiently when things go wrong.
Learning Objectives
By the end of this reading, you will be able to:
- Distinguish between syntax, runtime, and logic errors
- Use try/except to handle exceptions
- Raise and create custom exceptions
- Apply debugging strategies effectively
- Write defensive code
1. Types of Errors
Syntax Errors
Violations of language rules - caught before execution.
# Missing colon
if True
print("Hello")
# SyntaxError: expected ':'
# Mismatched parentheses
print("Hello"
# SyntaxError: unexpected EOF while parsing
# Invalid variable name
2fast = "value"
# SyntaxError: invalid syntax
How to fix: Read the error message carefully - it usually points to the line with the problem.
Runtime Errors (Exceptions)
Valid syntax that fails during execution.
# Division by zero
result = 10 / 0
# ZeroDivisionError: division by zero
# Invalid index
lst = [1, 2, 3]
print(lst[10])
# IndexError: list index out of range
# Key not found
d = {"a": 1}
print(d["b"])
# KeyError: 'b'
# Type mismatch
"hello" + 5
# TypeError: can only concatenate str (not "int") to str
Logic Errors
Code runs but produces wrong results.
# Bug: Should be (a + b) / 2, not a + b / 2
def average(a, b):
return a + b / 2 # Wrong due to operator precedence
print(average(4, 6)) # 7.0, not 5.0
These are the hardest to find - no error message tells you what's wrong.
2. Common Built-in Exceptions
# ValueError - wrong value for the type
int("hello") # ValueError: invalid literal for int()
# TypeError - wrong type for operation
len(5) # TypeError: object of type 'int' has no len()
# IndexError - sequence index out of range
[1, 2, 3][10] # IndexError
# KeyError - dictionary key not found
{"a": 1}["b"] # KeyError
# AttributeError - attribute doesn't exist
"hello".foo() # AttributeError: 'str' has no attribute 'foo'
# NameError - variable not defined
print(undefined_variable) # NameError
# FileNotFoundError
open("nonexistent.txt") # FileNotFoundError
# ZeroDivisionError
1 / 0 # ZeroDivisionError
# ImportError
import nonexistent_module # ModuleNotFoundError
# StopIteration - iterator exhausted
it = iter([1, 2])
next(it) # 1
next(it) # 2
next(it) # StopIteration
3. Exception Handling
Basic Try/Except
try:
result = 10 / 0
except ZeroDivisionError:
print("Cannot divide by zero!")
result = 0
print(f"Result: {result}")
Catching Multiple Exceptions
try:
value = int(input("Enter a number: "))
result = 10 / value
except ValueError:
print("That's not a valid number!")
except ZeroDivisionError:
print("Cannot divide by zero!")
# Or catch multiple in one clause
try:
# risky code
pass
except (ValueError, TypeError) as e:
print(f"Error: {e}")
The Exception Hierarchy
BaseException
├── SystemExit
├── KeyboardInterrupt
├── GeneratorExit
└── Exception
├── ValueError
├── TypeError
├── KeyError
├── IndexError
└── ... (most exceptions)
# Catch any standard exception
try:
risky_operation()
except Exception as e:
print(f"Something went wrong: {e}")
# Avoid catching BaseException - lets system exits through
The Else Clause
Runs if no exception occurred:
try:
result = 10 / 2
except ZeroDivisionError:
print("Division error!")
else:
print(f"Success! Result: {result}") # This runs
The Finally Clause
Always runs, regardless of exceptions:
try:
f = open("file.txt")
data = f.read()
except FileNotFoundError:
print("File not found!")
finally:
# Always runs - good for cleanup
print("Cleanup complete")
# Better: use context managers
with open("file.txt") as f:
data = f.read()
# File automatically closed
Getting Exception Information
try:
x = 1 / 0
except ZeroDivisionError as e:
print(f"Error type: {type(e).__name__}")
print(f"Error message: {e}")
# For full traceback
import traceback
try:
x = 1 / 0
except ZeroDivisionError:
traceback.print_exc()
4. Raising Exceptions
Basic Raise
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
try:
result = divide(10, 0)
except ValueError as e:
print(e) # "Cannot divide by zero"
Re-raising Exceptions
try:
risky_operation()
except Exception as e:
print(f"Logging error: {e}")
raise # Re-raise the same exception
Exception Chaining
try:
int("not a number")
except ValueError as e:
raise RuntimeError("Failed to parse input") from e
# Shows both the original and new exception
5. Custom Exceptions
Creating Custom Exceptions
class ValidationError(Exception):
"""Raised when validation fails"""
pass
class InsufficientFundsError(Exception):
"""Raised when account balance is too low"""
def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Insufficient funds: balance={balance}, requested={amount}"
)
# Usage
def withdraw(balance, amount):
if amount > balance:
raise InsufficientFundsError(balance, amount)
return balance - amount
try:
new_balance = withdraw(100, 150)
except InsufficientFundsError as e:
print(f"Error: {e}")
print(f"You're short by {e.amount - e.balance}")
Exception Hierarchy for Your Application
class AppError(Exception):
"""Base exception for our application"""
pass
class DatabaseError(AppError):
"""Database-related errors"""
pass
class NetworkError(AppError):
"""Network-related errors"""
pass
class AuthenticationError(AppError):
"""Authentication failures"""
pass
# Can catch all app errors with one clause
try:
perform_operation()
except AppError as e:
handle_app_error(e)
6. Debugging Strategies
Print Debugging
The simplest approach - add print statements:
def calculate_average(numbers):
print(f"DEBUG: Input numbers: {numbers}") # Debug
total = 0
for i, num in enumerate(numbers):
print(f"DEBUG: Processing index {i}, value {num}") # Debug
total += num
print(f"DEBUG: Total = {total}") # Debug
average = total / len(numbers)
print(f"DEBUG: Average = {average}") # Debug
return average
Better: Use logging instead of print:
import logging
logging.basicConfig(level=logging.DEBUG)
logger = logging.getLogger(__name__)
def calculate_average(numbers):
logger.debug(f"Input numbers: {numbers}")
total = sum(numbers)
logger.debug(f"Total: {total}")
return total / len(numbers)
Using a Debugger
Python's built-in debugger (pdb):
import pdb
def buggy_function(x):
pdb.set_trace() # Debugger stops here
y = x * 2
z = y + 1
return z
buggy_function(5)
Debugger commands:
n(next): Execute current lines(step): Step into function callc(continue): Run until next breakpointp variable: Print variable valuel(list): Show code around current lineq(quit): Exit debugger
VS Code / IDE debuggers are more user-friendly with visual interfaces.
Breakpoint (Python 3.7+)
def buggy_function(x):
breakpoint() # Cleaner than import pdb; pdb.set_trace()
return x * 2
Rubber Duck Debugging
Explain your code line-by-line to someone (or a rubber duck). Often the act of explaining reveals the bug.
Binary Search Debugging
- Add a print/breakpoint in the middle of the suspicious code
- If the bug is before that point, search the first half
- If after, search the second half
- Repeat until you find the bug
Reading Error Messages
Traceback (most recent call last):
File "example.py", line 10, in <module> # Where the call originated
result = process_data(data)
File "example.py", line 5, in process_data # One level deeper
return transform(item)
File "example.py", line 2, in transform # Where error occurred
return int(value)
ValueError: invalid literal for int() with base 10: 'hello'
Read from bottom to top: The last line is the actual error, work backward to find the cause.
7. Defensive Programming
Input Validation
def process_age(age):
# Validate early
if not isinstance(age, int):
raise TypeError("Age must be an integer")
if age < 0:
raise ValueError("Age cannot be negative")
if age > 150:
raise ValueError("Age seems unrealistic")
# Now we know age is valid
return age * 365 # Days alive
Guard Clauses
# Instead of deeply nested conditions
def process_user(user):
if user is not None:
if user.is_active:
if user.has_permission:
do_something(user)
# Use guard clauses
def process_user(user):
if user is None:
return
if not user.is_active:
return
if not user.has_permission:
return
do_something(user)
Fail Fast
Detect and report errors as early as possible:
class BankAccount:
def __init__(self, balance):
if balance < 0:
raise ValueError("Initial balance cannot be negative")
self._balance = balance
def withdraw(self, amount):
if amount <= 0:
raise ValueError("Withdrawal amount must be positive")
if amount > self._balance:
raise ValueError("Insufficient funds")
self._balance -= amount
Assertions
Check assumptions during development:
def calculate_average(numbers):
assert len(numbers) > 0, "Cannot average empty list"
assert all(isinstance(n, (int, float)) for n in numbers), "All elements must be numbers"
return sum(numbers) / len(numbers)
# Assertions can be disabled with python -O
# Don't use for user input validation!
LBYL vs EAFP
Look Before You Leap (LBYL):
if key in dictionary:
value = dictionary[key]
else:
value = default
Easier to Ask Forgiveness than Permission (EAFP):
try:
value = dictionary[key]
except KeyError:
value = default
Python prefers EAFP - it's often cleaner and can be faster.
8. Testing
Why Test?
- Catch bugs before users do
- Confidence when making changes
- Documentation of expected behavior
Basic Unit Test
import unittest
def add(a, b):
return a + b
class TestAdd(unittest.TestCase):
def test_positive_numbers(self):
self.assertEqual(add(2, 3), 5)
def test_negative_numbers(self):
self.assertEqual(add(-1, -1), -2)
def test_mixed(self):
self.assertEqual(add(-1, 1), 0)
if __name__ == "__main__":
unittest.main()
Using pytest (Popular Alternative)
# test_math.py
def add(a, b):
return a + b
def test_add_positive():
assert add(2, 3) == 5
def test_add_negative():
assert add(-1, -1) == -2
# Run with: pytest test_math.py
Testing Exceptions
import pytest
def divide(a, b):
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b
def test_divide_by_zero():
with pytest.raises(ValueError):
divide(10, 0)
9. Common Debugging Patterns
Off-by-One Errors
# Bug: doesn't include the last element
for i in range(len(lst) - 1): # Should be range(len(lst))
process(lst[i])
# Bug: index starts at 1 instead of 0
for i in range(1, n + 1):
array[i] # Off by one if array is 0-indexed
Mutable Default Arguments
# Bug!
def add_item(item, lst=[]):
lst.append(item)
return lst
print(add_item(1)) # [1]
print(add_item(2)) # [1, 2] - not [2]!
# Fix
def add_item(item, lst=None):
if lst is None:
lst = []
lst.append(item)
return lst
Variable Shadowing
list = [1, 2, 3] # Shadows built-in 'list'
# Later...
my_list = list("hello") # TypeError! 'list' is now a list, not a function
Comparison vs Assignment
x = 5
if x = 10: # SyntaxError in Python (but valid in C!)
pass
# Python prevents: if (x = 10) which should be if (x == 10)
Exercises
Basic
Write a function
safe_divide(a, b)that returnsa / bbut handles division by zero by returningNone.Write code that asks for a number, and keeps asking until a valid integer is entered:
# Should handle: "hello", "3.14", "", etc.
- What exception is raised by each of these?
int("hello")
[1, 2, 3][10]
{"a": 1}["b"]
1 / 0
Intermediate
Create a custom exception
InvalidEmailErrorand a functionvalidate_email(email)that raises it if the email doesn't contain "@".Write a function that reads a file and returns its contents, handling:
- File not found
- Permission denied
- Any other error
Debug this function (there are multiple bugs):
def find_average(numbers):
total = 0
for i in range(1, len(numbers)):
total = numbers[i]
return total / len(numbers)
Advanced
- Implement a retry decorator that retries a function n times if it raises an exception:
@retry(times=3)
def unreliable_function():
# Might fail randomly
pass
- Write a context manager that suppresses specified exceptions:
with suppress(ValueError, TypeError):
int("hello") # Silently ignored
print("Continues execution")
- Write unit tests for a
Stackclass withpush,pop, andpeekmethods.
Summary
- Errors: syntax (before run), runtime (during run), logic (wrong results)
- Use try/except to handle exceptions gracefully
- Raise exceptions to signal errors; create custom exceptions for clarity
- Debug with print/logging, debuggers, rubber duck, binary search
- Write defensive code: validate inputs, fail fast, use guard clauses
- Test your code to catch bugs early