Python & AI Tutorials Logo
Python Programming

26. Defensive Programming Techniques Using Exceptions and Validation

Defensive programming means writing code that anticipates problems before they occur. Instead of assuming everything will work perfectly, defensive code validates inputs, handles errors gracefully, and checks assumptions. This approach creates programs that are more reliable, easier to debug, and less likely to crash unexpectedly.

In previous chapters, we learned how to handle exceptions when they occur. Now we'll learn how to prevent many errors from happening in the first place, and how to catch problems early when they do occur.

26.1) Validating Function Arguments

Functions often receive data from other parts of your program or from users. If a function receives invalid data, it might produce incorrect results, crash with a confusing error, or cause problems elsewhere in your program. Argument validation means checking that function arguments meet your requirements before using them.

26.1.1) Why Validate Arguments?

Consider this function that calculates a student's grade percentage:

python
def calculate_percentage(points_earned, total_points):
    return (points_earned / total_points) * 100
 
# Using the function
percentage = calculate_percentage(85, 100)
print(f"Grade: {percentage}%")  # Output: Grade: 85.0%

This works fine with valid inputs. But what happens with problematic data?

python
# Problem 1: Division by zero
percentage = calculate_percentage(85, 0)  # ZeroDivisionError!
 
# Problem 2: Negative values (doesn't make sense)
percentage = calculate_percentage(-10, 100)  # -10.0%
 
# Problem 3: Points earned exceeds total (impossible)
percentage = calculate_percentage(120, 100)  # 120.0%

Without validation, the function either crashes or produces nonsensical results.

26.1.2) Basic Argument Validation with Conditionals

The simplest validation approach uses if statements to check arguments and raise exceptions when they're invalid:

python
def calculate_percentage(points_earned, total_points):
    # Validate total_points
    if total_points <= 0:
        raise ValueError("total_points must be positive")
    
    # Validate points_earned
    if points_earned < 0:
        raise ValueError("points_earned cannot be negative")
    
    if points_earned > total_points:
        raise ValueError("points_earned cannot exceed total_points")
    
    # All validations passed - safe to calculate
    return (points_earned / total_points) * 100
 
# Valid usage
percentage = calculate_percentage(85, 100)
print(f"Grade: {percentage}%")  # Output: Grade: 85.0%
 
# Invalid usage - clear error messages
try:
    percentage = calculate_percentage(85, 0)
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: total_points must be positive
 
try:
    percentage = calculate_percentage(-10, 100)
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: points_earned cannot be negative
 
try:
    percentage = calculate_percentage(120, 100)
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: points_earned cannot exceed total_points

Now when something goes wrong, the error message clearly explains what the problem is and how to fix it.

26.1.3) Validating Argument Types

Sometimes you need to ensure arguments are the correct type:

python
def calculate_discount(price, discount_percent):
    # Validate types
    if not isinstance(price, (int, float)):
        raise TypeError("price must be a number")
    
    if not isinstance(discount_percent, (int, float)):
        raise TypeError("discount_percent must be a number")
    
    # Validate values
    if price < 0:
        raise ValueError("price cannot be negative")
    
    if not (0 <= discount_percent <= 100):
        raise ValueError("discount_percent must be between 0 and 100")
    
    # Calculate discount
    discount_amount = price * (discount_percent / 100)
    return price - discount_amount
 
# Valid usage
final_price = calculate_discount(50.00, 20)
print(f"Final price: ${final_price:.2f}")  # Output: Final price: $40.00
 
# Type error
try:
    final_price = calculate_discount("50", 20)
except TypeError as e:
    print(f"Error: {e}")  # Output: Error: price must be a number
 
# Value error
try:
    final_price = calculate_discount(50.00, 150)
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: discount_percent must be between 0 and 100

The isinstance() function checks if an object is an instance of a specified type or types. We pass a tuple (int, float) to accept either integers or floats, since both are valid numeric types for prices.

When to validate types: Python's philosophy is "duck typing"—if an object behaves like what you need, use it. Type validation is most useful when:

  • You're writing a function that will be used by others
  • Type errors would cause confusing failures later
  • The function is part of a public API or library

26.1.4) Validating Collection Arguments

When functions accept lists, dictionaries, or other collections, validate both the collection and its contents:

python
def calculate_average_grade(grades):
    # Validate the collection itself
    if not isinstance(grades, list):
        raise TypeError("grades must be a list")
    
    if len(grades) == 0:
        raise ValueError("grades list cannot be empty")
    
    # Validate each grade in the collection
    for i, grade in enumerate(grades):
        if not isinstance(grade, (int, float)):
            raise TypeError(f"grade at index {i} must be a number, got {type(grade).__name__}")
        
        if not (0 <= grade <= 100):
            raise ValueError(f"grade at index {i} must be between 0 and 100, got {grade}")
    
    # All validations passed
    return sum(grades) / len(grades)
 
# Valid usage
grades = [85, 92, 78, 95]
average = calculate_average_grade(grades)
print(f"Average: {average:.1f}")  # Output: Average: 87.5
 
# Empty list error
try:
    average = calculate_average_grade([])
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: grades list cannot be empty
 
# Invalid grade type
try:
    average = calculate_average_grade([85, "92", 78])
except TypeError as e:
    print(f"Error: {e}")  # Output: Error: grade at index 1 must be a number, got str
 
# Invalid grade value
try:
    average = calculate_average_grade([85, 92, 150])
except ValueError as e:
    print(f"Error: {e}")  # Output: Error: grade at index 2 must be between 0 and 100, got 150

Notice how we include the index in error messages when validating collection elements. This helps identify exactly which item is problematic, especially in large collections.

Invalid Type

Invalid Value

Valid

Function Called

Validate
Arguments

Raise TypeError

Raise ValueError

Execute Function Logic

Return Result

Caller Handles Exception

26.2) Checking User Input for Validity

User input is inherently unreliable—users make typos, misunderstand instructions, or enter data in unexpected formats. Validating user input prevents these mistakes from causing program crashes or incorrect results.

26.2.1) Basic Input Validation Pattern

The fundamental pattern for input validation combines input() with validation checks:

python
# Get user input
age_str = input("Enter your age: ")
 
# Validate the input
try:
    age = int(age_str)
    if age < 0:
        print("Error: Age cannot be negative")
    elif age > 150:
        print("Error: Age seems unrealistic")
    else:
        print(f"You are {age} years old")
except ValueError:
    print("Error: Please enter a valid number")

This pattern has three parts:

  1. Get the input as a string
  2. Try to convert it to the needed type
  3. Check if the converted value is valid

Let's see this in action with different inputs:

python
# Valid input
# User enters: 25
# Output: You are 25 years old
 
# Invalid type
# User enters: twenty-five
# Output: Error: Please enter a valid number
 
# Invalid value (negative)
# User enters: -5
# Output: Error: Age cannot be negative
 
# Invalid value (unrealistic)
# User enters: 200
# Output: Error: Age seems unrealistic

26.2.2) Validating Input Ranges and Formats

Some inputs must fall within specific ranges or match particular formats:

python
# Validating a month (1-12)
month_str = input("Enter month (1-12): ")
try:
    month = int(month_str)
    if not (1 <= month <= 12):
        print("Error: Month must be between 1 and 12")
    else:
        print(f"Month: {month}")
except ValueError:
    print("Error: Please enter a whole number")
 
# Validating email format (simple check)
email = input("Enter email: ")
if '@' not in email or '.' not in email:
    print("Error: Email must contain @ and .")
else:
    print(f"Email: {email}")
 
# Validating yes/no input
response = input("Continue? (yes/no): ").lower().strip()
if response not in ['yes', 'no', 'y', 'n']:
    print("Error: Please answer yes or no")
else:
    if response in ['yes', 'y']:
        print("Continuing...")
    else:
        print("Stopping...")

The email validation here is intentionally simple—it just checks for basic structure. Real email validation is much more complex and typically uses regular expressions (which we'll learn about in Chapter 39).

26.2.3) Providing Helpful Error Messages

Good error messages tell users exactly what went wrong and how to fix it:

python
# Poor error message
password = input("Enter password: ")
if len(password) < 8:
    print("Error: Invalid password")  # Not helpful!
 
# Better error message
password = input("Enter password: ")
if len(password) < 8:
    print("Error: Password must be at least 8 characters long")
    print(f"Your password is only {len(password)} characters")
 
# Even better - explain all requirements upfront
print("Password requirements:")
print("- At least 8 characters")
print("- Must contain at least one number")
password = input("Enter password: ")
 
# Check length
if len(password) < 8:
    print(f"Error: Password too short ({len(password)} characters)")
    print("Password must be at least 8 characters")
# Check for digit
elif not any(char.isdigit() for char in password):
    print("Error: Password must contain at least one number")
else:
    print("Password accepted")

The any() function returns True if any element in an iterable is true. Here, char.isdigit() checks if each character is a digit, and any() tells us if at least one character passed the test.

Conversion Fails

Conversion Succeeds

Out of Range

Invalid Format

Valid

Get User Input

Try Type Conversion

ValueError:
Invalid Format

Check Value
Constraints

Value Error:
Clear Message

Format Error:
Clear Message

Use Input

Show Error,
Explain Expected Format

26.3) Combining input(), Loops, and try/except for Robust Input Handling

Single validation checks are useful, but they don't handle persistent user errors. If a user enters invalid data, your program should give them another chance. Combining loops with validation creates robust input handling that keeps asking until it gets valid data.

26.3.1) The Basic Input Loop Pattern

The fundamental pattern uses a while loop that continues until valid input is received:

python
# Keep asking until we get a valid age
while True:
    age_str = input("Enter your age: ")
    try:
        age = int(age_str)
        if age < 0:
            print("Error: Age cannot be negative. Please try again.")
        elif age > 150:
            print("Error: Age seems unrealistic. Please try again.")
        else:
            # Valid input - break out of loop
            break
    except ValueError:
        print("Error: Please enter a valid number.")
 
print(f"You are {age} years old")

This pattern has several key elements:

  • while True: creates an infinite loop
  • Validation happens inside the loop
  • break exits the loop when input is valid
  • Error messages encourage the user to try again

Let's see how this handles various inputs:

python
# Example interaction:
# Enter your age: twenty
# Error: Please enter a valid number.
# Enter your age: -5
# Error: Age cannot be negative. Please try again.
# Enter your age: 25
# You are 25 years old

26.3.2) Creating Reusable Input Functions

When you need the same type of validated input in multiple places, create a function:

python
def get_positive_integer(prompt):
    """Keep asking until user enters a positive integer."""
    while True:
        try:
            value = int(input(prompt))
            if value <= 0:
                print("Error: Please enter a positive number.")
            else:
                return value
        except ValueError:
            print("Error: Please enter a valid whole number.")
 
def get_number_in_range(prompt, min_value, max_value):
    """Keep asking until user enters a number in the specified range."""
    while True:
        try:
            value = float(input(prompt))
            if value < min_value or value > max_value:
                print(f"Error: Please enter a number between {min_value} and {max_value}.")
            else:
                return value
        except ValueError:
            print("Error: Please enter a valid number.")
 
# Using the functions
quantity = get_positive_integer("Enter quantity: ")
print(f"Quantity: {quantity}")
 
grade = get_number_in_range("Enter grade (0-100): ", 0, 100)
print(f"Grade: {grade}")
 
temperature = get_number_in_range("Enter temperature (-50 to 50): ", -50, 50)
print(f"Temperature: {temperature}°C")

These functions encapsulate the validation logic, making your main code cleaner and more readable. They also ensure consistent validation behavior throughout your program.

26.4) Using Assertions for Development-Time Invariant Checks

Assertions are a special kind of check used during development to verify that your code's assumptions are correct. Unlike validation (which handles expected errors from users or external data), assertions catch programming mistakes—situations that should never happen if your code is correct.

26.4.1) What Assertions Are and When to Use Them

An assertion is a statement that should always be true at a particular point in your code. If it's false, something is fundamentally wrong with your program logic:

python
def calculate_average(numbers):
    # This should never happen if the function is called correctly
    assert len(numbers) > 0, "numbers list cannot be empty"
    
    return sum(numbers) / len(numbers)
 
# Correct usage
grades = [85, 90, 78]
average = calculate_average(grades)
print(f"Average: {average:.1f}")  # Output: Average: 84.3
 
# Incorrect usage - triggers assertion
empty_list = []
average = calculate_average(empty_list)  # AssertionError: numbers list cannot be empty

When an assertion fails, Python raises an AssertionError with your message. This immediately stops the program and shows you exactly where your assumption was violated.

Key distinction:

  • Validation (using if and raise): For handling expected problems from users or external data
  • Assertions: For catching programming bugs during development
python
# Validation - handles expected user errors
def get_positive_number(prompt):
    while True:
        try:
            value = float(input(prompt))
            if value <= 0:
                print("Error: Please enter a positive number.")
            else:
                return value
        except ValueError:
            print("Error: Please enter a valid number.")
 
# Assertion - catches programming mistakes
def calculate_discount(price, discount_rate):
    # These should never be violated if the program is written correctly
    assert price >= 0, "price should be non-negative"
    assert 0 <= discount_rate <= 1, "discount_rate should be between 0 and 1"
    
    return price * (1 - discount_rate)

26.4.2) Checking Function Preconditions

Assertions are excellent for verifying that a function's preconditions (requirements that must be true before the function executes) are met:

python
def get_list_element(items, index):
    """Get an element from a list at the specified index."""
    # Preconditions
    assert isinstance(items, list), "items must be a list"
    assert isinstance(index, int), "index must be an integer"
    assert 0 <= index < len(items), f"index {index} out of range for list of length {len(items)}"
    
    return items[index]
 
# Correct usage
numbers = [10, 20, 30, 40]
value = get_list_element(numbers, 2)
print(f"Value: {value}")  # Output: Value: 30
 
# Programming error - wrong type
value = get_list_element("not a list", 0)  # AssertionError: items must be a list
 
# Programming error - invalid index
value = get_list_element(numbers, 10)  # AssertionError: index 10 out of range for list of length 4

These assertions help catch bugs during development. If you accidentally pass the wrong type or an invalid index, the assertion immediately tells you what went wrong.

26.4.3) Checking Function Postconditions

Postconditions are conditions that must be true after a function executes. Assertions can verify that your function produced valid results:

python
def calculate_percentage(part, whole):
    """Calculate what percentage 'part' is of 'whole'."""
    # Preconditions
    assert whole > 0, "whole must be positive"
    assert part >= 0, "part must be non-negative"
    
    # Calculate percentage
    percentage = (part / whole) * 100
    
    # Postcondition - result should be valid percentage
    assert 0 <= percentage <= 100, f"percentage {percentage} is outside valid range"
    
    return percentage
 
# This works correctly
percentage = calculate_percentage(25, 100)
print(f"Percentage: {percentage}%")  # Output: Percentage: 25.0%
 
# This reveals a logic error in our function
# (we didn't check that part <= whole)
percentage = calculate_percentage(150, 100)  # AssertionError: percentage 150.0 is outside valid range

The postcondition assertion caught a bug in our function—we forgot to validate that part doesn't exceed whole. This is exactly what assertions are for: catching programming mistakes.

26.4.4) Assertions Can Be Disabled

An important characteristic of assertions is that they can be disabled when running Python with the -O (optimize) flag:

python
# This file is named test_assertions.py
def divide(a, b):
    assert b != 0, "divisor cannot be zero"
    return a / b
 
result = divide(10, 2)
print(f"Result: {result}")
 
result = divide(10, 0)  # AssertionError when assertions are enabled

Running normally:

bash
python test_assertions.py
# Output: Result: 5.0
# Then: AssertionError: divisor cannot be zero

Running with optimization:

bash
python -O test_assertions.py
# Output: Result: 5.0
# Then: ZeroDivisionError: division by zero

This is why assertions should never be used for validation of external data—if someone runs your program with -O, all assertions are skipped. Use assertions only for catching programming bugs during development and testing.

Condition True

Condition False

Code Execution

Assertion Check

Continue Execution

Raise AssertionError
with Message

Program Stops
Shows Traceback

Developer Fixes Bug

© 2025. Primesoft Co., Ltd.
support@primesoft.ai