Debug With Assert

This document covers debugging Python programs using assert statements including assertion syntax, sanity checks, precondition validation, and best practices for catching bugs early in development. Proactive bug detection technique.

This document explores debugging Python programs using assert statements as sanity checks to catch bugs early in development. Assertions validate assumptions, check preconditions, verify intermediate states, and provide clear error messages when conditions fail, enabling proactive bug detection throughout the coding process.


Introduction

A developer’s worst nightmare is spending hours developing code only to discover multiple bugs right before deployment. Instead of waiting until the last minute to check code correctness, developers should test and validate throughout the development process. Assert statements provide a mechanism for these continuous sanity checks.


What Are Assertions

Definition

Assertions are logical tests that developers use as sanity checks when writing code. In Python, the assert statement writes these sanity checks with one fundamental principle: the condition should always be true. If the condition is false, it indicates a bug in the program.

How Assertions Work

When an assert statement evaluates to false:

  • Program execution terminates immediately
  • An AssertionError exception is raised
  • Optional error message is displayed
  • Developer can fix the bug before continuing

Assert Statement Syntax

Basic Syntax

1assert condition

Syntax with Error Message

1assert condition, "Error message"

Components

ComponentDescriptionRequired
assert keywordStarts the assertion statementYes
conditionBoolean expression to testYes
messageError message if condition failsNo

Basic Assert Examples

Assertion That Passes

When the condition is true, no error is raised:

1a = 3 + 4
2assert a == 7
3print("Assertion passed, program continues")

Output:

1Assertion passed, program continues

Assertion That Fails

When the condition is false, program terminates:

1a = 3 + 4
2assert a == 8  # This will fail!
3print("This line never executes")

Output:

1Traceback (most recent call last):
2  File "example.py", line 2, in <module>
3    assert a == 8
4AssertionError

Assertion with Error Message

Provide helpful context when assertion fails:

1a = 3 + 4
2assert a == 8, f"Expected 8, but got {a}"

Output:

1Traceback (most recent call last):
2  File "example.py", line 2, in <module>
3    assert a == 8, f"Expected 8, but got {a}"
4AssertionError: Expected 8, but got 7

Practical Assert Use Cases

Validating Function Arguments

Ensure function receives valid input:

 1def read_file_and_do_something(filename):
 2    assert filename != "", "You must specify a filename!"
 3
 4    with open(filename, "r") as fp:
 5        content = fp.read()
 6        # Process content...
 7
 8    return content
 9
10# This works
11result = read_file_and_do_something("data.txt")
12
13# This raises AssertionError with clear message
14result = read_file_and_do_something("")  # AssertionError: You must specify a filename!

Checking Preconditions

Verify conditions before performing operations:

 1def divide(numerator, denominator):
 2    assert denominator != 0, "Denominator cannot be zero!"
 3    return numerator / denominator
 4
 5def calculate_average(numbers):
 6    assert len(numbers) > 0, "Cannot calculate average of empty list!"
 7    return sum(numbers) / len(numbers)
 8
 9def get_item(items, index):
10    assert 0 <= index < len(items), f"Index {index} out of range [0, {len(items)-1}]"
11    return items[index]

Validating Data Types

 1def process_user(user_id, username):
 2    assert isinstance(user_id, int), f"user_id must be int, got {type(user_id)}"
 3    assert isinstance(username, str), f"username must be str, got {type(username)}"
 4    assert len(username) > 0, "username cannot be empty"
 5
 6    # Process user...
 7    print(f"Processing user {user_id}: {username}")
 8
 9process_user(123, "alice")  # Works
10process_user("123", "alice")  # AssertionError: user_id must be int, got <class 'str'>

Checking Value Ranges

 1def set_temperature(temp_celsius):
 2    assert -273.15 <= temp_celsius <= 1000, f"Temperature {temp_celsius}°C is physically impossible"
 3    print(f"Temperature set to {temp_celsius}°C")
 4
 5def set_percentage(value):
 6    assert 0 <= value <= 100, f"Percentage must be between 0 and 100, got {value}"
 7    print(f"Value set to {value}%")
 8
 9def set_age(age):
10    assert 0 <= age <= 150, f"Age {age} is not realistic"
11    assert isinstance(age, int), "Age must be an integer"
12    print(f"Age set to {age}")

Assertions for Intermediate State Validation

Verifying Calculations

 1def calculate_total_with_tax(subtotal, tax_rate):
 2    assert subtotal >= 0, "Subtotal cannot be negative"
 3    assert 0 <= tax_rate <= 1, f"Tax rate must be between 0 and 1, got {tax_rate}"
 4
 5    tax = subtotal * tax_rate
 6    total = subtotal + tax
 7
 8    # Verify calculation makes sense
 9    assert total >= subtotal, "Total should be at least equal to subtotal"
10
11    return total

Checking Algorithm Invariants

 1def binary_search(arr, target):
 2    assert len(arr) > 0, "Array cannot be empty"
 3    assert all(arr[i] <= arr[i+1] for i in range(len(arr)-1)), "Array must be sorted"
 4
 5    left, right = 0, len(arr) - 1
 6
 7    while left <= right:
 8        # Invariant: target is in arr[left:right+1] if it exists
 9        assert 0 <= left <= len(arr), "Left boundary out of range"
10        assert 0 <= right < len(arr), "Right boundary out of range"
11
12        mid = (left + right) // 2
13
14        if arr[mid] == target:
15            return mid
16        elif arr[mid] < target:
17            left = mid + 1
18        else:
19            right = mid - 1
20
21    return -1  # Not found

Benefits of Assertions

Advantages

BenefitDescription
Early bug detectionCatches bugs immediately when assumptions violated
Prevents cascading errorsStops execution before invalid state propagates
DocumentationExpresses assumptions explicitly in code
Debugging aidIdentifies exact location where assumption fails
Development-time checksHelps during development without production overhead
Clear error messagesCustom messages explain what went wrong

Comparison with Exception Handling

AspectAssertionsExceptions
PurposeCheck internal correctnessHandle expected errors
When to useDevelopment and testingProduction code
Can be disabledYes (with -O flag)No
User input validationNoYes
Programming errorsYesSometimes
Recovery possibleNoYes

Assert vs If-Raise Pattern

Using Assert

1def calculate_discount(price, discount_rate):
2    assert 0 <= discount_rate <= 1, "Discount rate must be between 0 and 1"
3    return price * (1 - discount_rate)

Using If-Raise (for User Input)

1def calculate_discount(price, discount_rate):
2    if not (0 <= discount_rate <= 1):
3        raise ValueError("Discount rate must be between 0 and 1")
4    return price * (1 - discount_rate)

When to Use Each

ScenarioUse AssertUse If-Raise
Internal function calls
Developer-controlled values
User input validation
External API data
Production error handling
Development sanity checks

Advanced Assert Examples

Multiple Conditions

1def create_user(username, email, age):
2    assert username and email and age, "All parameters are required"
3    assert isinstance(username, str) and len(username) >= 3, "Username must be string with at least 3 characters"
4    assert '@' in email and '.' in email, "Email must be valid format"
5    assert isinstance(age, int) and age >= 18, "Age must be integer >= 18"
6
7    print(f"User created: {username}, {email}, {age}")

Assertions with Complex Conditions

 1def process_matrix(matrix):
 2    # Check it's a 2D list
 3    assert isinstance(matrix, list), "Matrix must be a list"
 4    assert all(isinstance(row, list) for row in matrix), "Matrix must be 2D list"
 5
 6    # Check all rows have same length
 7    if len(matrix) > 0:
 8        row_length = len(matrix[0])
 9        assert all(len(row) == row_length for row in matrix), "All rows must have same length"
10
11    # Check all elements are numbers
12    assert all(
13        all(isinstance(val, (int, float)) for val in row)
14        for row in matrix
15    ), "All matrix elements must be numbers"
16
17    print(f"Processing {len(matrix)}x{len(matrix[0]) if matrix else 0} matrix")

Assertions in Loops

 1def find_maximum(numbers):
 2    assert len(numbers) > 0, "Cannot find maximum of empty list"
 3
 4    max_val = numbers[0]
 5
 6    for num in numbers:
 7        assert isinstance(num, (int, float)), f"Expected number, got {type(num)}"
 8
 9        if num > max_val:
10            max_val = num
11
12        # Invariant: max_val is the largest value seen so far
13        assert all(max_val >= n for n in numbers[:numbers.index(num)+1]), "Invariant violated"
14
15    return max_val

Disabling Assertions

Running with Optimization

Assertions are disabled when Python runs in optimized mode:

1# Normal mode - assertions enabled
2python3 script.py
3
4# Optimized mode - assertions disabled
5python3 -O script.py
6
7# Check if assertions are enabled
8python3 -c "print(__debug__)"  # True
9python3 -O -c "print(__debug__)"  # False

Why Assertions Can Be Disabled

ReasonExplanation
PerformanceRemove overhead in production
Development toolAssertions are primarily for development
Separate concernsRuntime errors should use exceptions

Best Practices

Do’s and Don’ts

DoDon’t
Use for development-time checksUse for production error handling
Check internal invariantsValidate user input
Document assumptionsRely on them for security
Add descriptive messagesPut side effects in assertions
Test boundary conditionsUse as control flow
Verify preconditionsCatch AssertionError exceptions

Writing Effective Assert Messages

 1# Bad - no context
 2assert x > 0
 3
 4# Better - explains what's wrong
 5assert x > 0, "x must be positive"
 6
 7# Best - includes actual value
 8assert x > 0, f"x must be positive, got {x}"
 9
10# Excellent - includes context and expected range
11assert 0 < x <= 100, f"x must be in range (0, 100], got {x}"

Strategic Placement

 1def process_data(data, config):
 2    # Preconditions at function start
 3    assert data is not None, "Data cannot be None"
 4    assert len(data) > 0, "Data cannot be empty"
 5    assert isinstance(config, dict), "Config must be dictionary"
 6
 7    # Process data
 8    result = transform_data(data)
 9
10    # Check intermediate state
11    assert len(result) == len(data), "Transform should preserve length"
12
13    filtered = filter_data(result, config)
14
15    # Postcondition before return
16    assert filtered is not None, "Filter should not return None"
17
18    return filtered

Practical Example: Enhanced File Reader

Basic Version (Poor Error Handling)

1def read_file_and_do_something(filename):
2    with open(filename, "r") as fp:
3        content = fp.read()
4    return content
5
6# What happens with empty filename?
7result = read_file_and_do_something("")  # Confusing FileNotFoundError

Improved with Assert (No Message)

1def read_file_and_do_something(filename):
2    assert filename != ""
3
4    with open(filename, "r") as fp:
5        content = fp.read()
6    return content
7
8# Better, but error message is blank
9result = read_file_and_do_something("")  # AssertionError (no message)

Best Version (With Clear Message)

 1def read_file_and_do_something(filename):
 2    assert filename != "", "You must specify a filename!"
 3    assert isinstance(filename, str), f"Filename must be string, got {type(filename)}"
 4
 5    with open(filename, "r") as fp:
 6        content = fp.read()
 7
 8    assert content is not None, "File content should not be None"
 9
10    return content
11
12# Clear, helpful error message
13result = read_file_and_do_something("")  # AssertionError: You must specify a filename!

Testing with Assertions

Simple Test Function

 1def test_calculate_average():
 2    # Test normal case
 3    result = calculate_average([10, 20, 30])
 4    assert result == 20, f"Expected 20, got {result}"
 5
 6    # Test single element
 7    result = calculate_average([42])
 8    assert result == 42, f"Expected 42, got {result}"
 9
10    # Test floating point
11    result = calculate_average([1, 2, 3])
12    assert abs(result - 2.0) < 0.0001, f"Expected 2.0, got {result}"
13
14    print("All tests passed!")
15
16test_calculate_average()

Key Takeaways

Using assert statements in code enables checking that code works properly, detecting bugs early, and maintaining developer sanity. Assert statements should validate internal correctness assumptions during development but should not replace proper exception handling for user input or production error conditions. Effective assert messages clearly explain what went wrong and include actual values when relevant. Use assert statements throughout code to create robust and reliable implementations, saving time from extra debugging and code rewrites.


Conclusion

Assertions are powerful debugging tools that catch bugs early by validating assumptions throughout code execution. They serve as executable documentation of preconditions, postconditions, and invariants. When used correctly, assertions help developers build confidence in their code during development. However, assertions should complement, not replace, proper error handling with exceptions for production scenarios. The key is understanding when to use assertions for internal checks versus when to use exceptions for handling expected error conditions.


FAQ