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

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 line
  • s (step): Step into function call
  • c (continue): Run until next breakpoint
  • p variable: Print variable value
  • l (list): Show code around current line
  • q (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

  1. Add a print/breakpoint in the middle of the suspicious code
  2. If the bug is before that point, search the first half
  3. If after, search the second half
  4. 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()
# 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

  1. Write a function safe_divide(a, b) that returns a / b but handles division by zero by returning None.

  2. Write code that asks for a number, and keeps asking until a valid integer is entered:

# Should handle: "hello", "3.14", "", etc.
  1. What exception is raised by each of these?
int("hello")
[1, 2, 3][10]
{"a": 1}["b"]
1 / 0

Intermediate

  1. Create a custom exception InvalidEmailError and a function validate_email(email) that raises it if the email doesn't contain "@".

  2. Write a function that reads a file and returns its contents, handling:

    • File not found
    • Permission denied
    • Any other error
  3. 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

  1. 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
  1. Write a context manager that suppresses specified exceptions:
with suppress(ValueError, TypeError):
    int("hello")  # Silently ignored
print("Continues execution")
  1. Write unit tests for a Stack class with push, pop, and peek methods.

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

Next Module

Arrays and Dynamic Arrays →