Skip to main content

Errors and Exceptions

In this chapter, we'll learn how to handle errors gracefully in Python using TDD. We'll cover:

  • Understanding Python exceptions
  • try, except, finally, and else blocks
  • Raising exceptions with raise
  • Creating custom exception classes
  • Testing that exceptions are raised correctly

Understanding exceptions

When something goes wrong in Python, it raises an exception:

# ZeroDivisionError
1 / 0

# IndexError
[1, 2, 3][10]

# KeyError
{"a": 1}["b"]

# TypeError
"hello" + 5

# ValueError
int("not a number")

Unhandled exceptions crash your program. We use exception handling to deal with errors gracefully.

The try/except block

Write the test first

Let's write a function that safely divides two numbers:

from calculator import safe_divide


def test_safe_divide():
got = safe_divide(10, 2)
want = 5.0
assert got == want


def test_safe_divide_by_zero():
got = safe_divide(10, 0)
want = None
assert got is None

Make it pass

def safe_divide(a, b):
"""Divide a by b, returning None if b is zero."""
try:
return a / b
except ZeroDivisionError:
return None

The try block contains code that might raise an exception. The except block handles it.

Catching specific exceptions

Always catch specific exceptions, not bare except:

# Good - catches specific exception
try:
result = int(user_input)
except ValueError:
print("Please enter a valid number")

# Bad - catches everything, including KeyboardInterrupt
try:
result = int(user_input)
except: # Don't do this!
print("Something went wrong")

Catching multiple exceptions

def parse_value(value):
"""Parse a value that might be a string number or list index."""
try:
return int(value)
except (ValueError, TypeError) as e:
return None

Or handle them differently:

def get_item(data, key):
try:
return data[key]
except KeyError:
return f"Key {key} not found"
except IndexError:
return f"Index {key} out of range"
except TypeError:
return f"Cannot index with {type(key)}"

The else clause

The else block runs if no exception was raised:

def test_parse_with_else():
got = parse_number("42")
want = (42, True)
assert got == want


def test_parse_invalid():
got = parse_number("not a number")
want = (None, False)
assert got == want

Implementation:

def parse_number(text):
"""Parse a number, returning (value, success) tuple."""
try:
value = int(text)
except ValueError:
return None, False
else:
return value, True

The finally clause

The finally block always runs, regardless of exceptions:

def read_file(filename):
"""Read a file, ensuring it's always closed."""
file = None
try:
file = open(filename, 'r')
return file.read()
except FileNotFoundError:
return None
finally:
if file:
file.close() # Always runs

Note: In practice, use context managers (with statement) for file handling.

Raising exceptions

Use raise to signal errors in your code:

Write the test first

import pytest
from calculator import divide


def test_divide():
got = divide(10, 2)
want = 5.0
assert got == want


def test_divide_by_zero_raises():
with pytest.raises(ValueError, match="Cannot divide by zero"):
divide(10, 0)

Make it pass

def divide(a, b):
"""Divide a by b.

Raises:
ValueError: If b is zero.
"""
if b == 0:
raise ValueError("Cannot divide by zero")
return a / b

Testing exceptions with pytest

pytest provides several ways to test exceptions:

Basic exception testing

import pytest


def test_raises_value_error():
with pytest.raises(ValueError):
int("not a number")

Checking the exception message

def test_raises_with_message():
with pytest.raises(ValueError, match="invalid literal"):
int("not a number")

Inspecting the exception

def test_exception_details():
with pytest.raises(ValueError) as exc_info:
int("not a number")

assert "invalid literal" in str(exc_info.value)

Custom exceptions

Create custom exceptions for your domain:

class BankError(Exception):
"""Base exception for bank operations."""
pass


class InsufficientFundsError(BankError):
"""Raised when withdrawal exceeds balance."""

def __init__(self, balance, amount):
self.balance = balance
self.amount = amount
super().__init__(
f"Cannot withdraw ${amount}: only ${balance} available"
)


class AccountLockedError(BankError):
"""Raised when account is locked."""
pass

Testing custom exceptions

import pytest
from bank import BankAccount, InsufficientFundsError


def test_withdraw_insufficient_funds():
account = BankAccount("Alice", 100)

with pytest.raises(InsufficientFundsError) as exc_info:
account.withdraw(150)

assert exc_info.value.balance == 100
assert exc_info.value.amount == 150

Implementation:

class BankAccount:
def __init__(self, owner, balance=0):
self.owner = owner
self.balance = balance

def withdraw(self, amount):
if amount > self.balance:
raise InsufficientFundsError(self.balance, amount)
self.balance -= amount

Exception chaining

When raising a new exception from another, use from:

def load_config(filename):
"""Load configuration from a file."""
try:
with open(filename) as f:
return parse_config(f.read())
except FileNotFoundError as e:
raise ConfigurationError(f"Config file not found: {filename}") from e
except ValueError as e:
raise ConfigurationError(f"Invalid config format") from e

The original exception is preserved in __cause__.

Re-raising exceptions

Sometimes you want to log an exception and re-raise it:

def process_data(data):
try:
return transform(data)
except ValueError:
print(f"Failed to process: {data}")
raise # Re-raises the current exception

Best practices

1. Be specific

# Good
try:
value = int(text)
except ValueError:
handle_invalid_number()

# Bad
try:
value = int(text)
except Exception: # Too broad
handle_error()

2. Don't silence exceptions

# Bad - silently ignores errors
try:
do_something()
except Exception:
pass

# Good - at least log it
try:
do_something()
except Exception as e:
logger.error(f"Failed: {e}")

3. Use exceptions for exceptional cases

# Bad - using exceptions for control flow
def find_user(users, name):
try:
return next(u for u in users if u.name == name)
except StopIteration:
return None

# Good - normal control flow
def find_user(users, name):
for user in users:
if user.name == name:
return user
return None

4. Document exceptions

def parse_json(text):
"""Parse JSON text.

Args:
text: The JSON string to parse.

Returns:
The parsed Python object.

Raises:
ValueError: If the text is not valid JSON.
"""
import json
try:
return json.loads(text)
except json.JSONDecodeError as e:
raise ValueError(f"Invalid JSON: {e}") from e

A complete example: Validation

Let's build a user validator with proper exception handling:

# test_validator.py
import pytest
from validator import validate_user, ValidationError


def test_valid_user():
user = {"name": "Alice", "email": "alice@example.com", "age": 25}
# Should not raise
validate_user(user)


def test_missing_name():
user = {"email": "alice@example.com", "age": 25}
with pytest.raises(ValidationError, match="name is required"):
validate_user(user)


def test_invalid_email():
user = {"name": "Alice", "email": "not-an-email", "age": 25}
with pytest.raises(ValidationError, match="Invalid email"):
validate_user(user)


def test_invalid_age():
user = {"name": "Alice", "email": "alice@example.com", "age": -5}
with pytest.raises(ValidationError, match="Age must be positive"):
validate_user(user)

Implementation:

# validator.py
import re


class ValidationError(Exception):
"""Raised when validation fails."""
pass


def validate_user(user):
"""Validate a user dictionary.

Raises:
ValidationError: If validation fails.
"""
if "name" not in user:
raise ValidationError("name is required")

if "email" not in user:
raise ValidationError("email is required")

if not re.match(r"[^@]+@[^@]+\.[^@]+", user["email"]):
raise ValidationError("Invalid email format")

if user.get("age", 0) < 0:
raise ValidationError("Age must be positive")

Wrapping up

We've covered:

  • try/except - Handle exceptions gracefully
  • except with type - Catch specific exception types
  • else clause - Run code only if no exception occurred
  • finally clause - Always run cleanup code
  • raise - Signal errors with exceptions
  • Custom exceptions - Create domain-specific error types
  • pytest.raises - Test that exceptions are raised
  • Exception chaining - Preserve original exception with from

TDD and exceptions

When testing exception handling:

  1. Test the happy path (no exceptions)
  2. Test that exceptions are raised for error conditions
  3. Verify exception messages are helpful
  4. Test exception attributes for custom exceptions

Good exception handling makes code more robust and easier to debug. Always be specific about what you catch, and always test your error paths!