Previous | Tutorial index | Next

📚 Tutorial 5: Preventing Future Exceptions with assert

Learning Objectives

1. Introduction: The Art of Defensive Programming

As a programmer, you make many assumptions when writing code: "This list will never be empty," "This variable will always be positive," "This key will always exist in the dictionary." But what if those assumptions are wrong? Instead of letting the program produce incorrect results or crash mysteriously, you can use assertions to explicitly check your assumptions.

The assert statement in Python is a powerful debugging tool that helps you catch logical errors early, during development. It allows you to test a condition and, if the condition is False, immediately raise an AssertionError. This stops the program and gives you a clear signal that something you thought was impossible actually happened.

Key Idea: Assertions are your safety net for catching bugs—they are not for handling expected runtime errors like invalid user input.

2. Key Terms

Before diving into the content, familiarise yourself with these key terms:

Term Definition
Assertion A statement that tests a condition and raises an AssertionError if the condition is False.
assert statement The Python syntax for assertions: assert condition, "message".
Precondition A condition that must be true before a function executes.
Postcondition A condition that must be true after a function returns.
Invariant A condition that must always be true for a class or data structure.
-O flag The Python command-line flag that disables assertions (optimize mode).
__debug__ A built-in variable that is True when assertions are enabled, False when disabled.

3. Real-World Analogy: The Building Inspector

Imagine you are an architect designing a skyscraper. You have detailed blueprints (your program). Before the building is occupied, you hire a building inspector (your assert statements) to check that certain critical assumptions hold: "The foundation is at least 10 meters deep," "The steel beams are properly welded," etc. If any of these checks fail, the inspector raises a red flag (an AssertionError), and the building is not allowed to open. These checks are performed during the inspection phase (development). Once the building is certified (production), you don't re-inspect every beam—you trust that the assumptions are correct.

Similarly, assertions are meant to catch programming errors during development and testing. In production, they can be turned off for performance.

4. The assert Statement: Syntax and Basic Usage

4.1 Syntax

assert condition, "Optional error message"

If the condition is True, nothing happens and the program continues. If it is False, an AssertionError is raised with the provided message (or no message if omitted).

4.2 Basic Examples

# Example 1: Simple assertion x = 10 assert x > 0 # Passes, nothing happens # Example 2: Assertion with message x = -5 assert x > 0, "x must be positive" # Raises AssertionError: x must be positive # Example 3: Assertion in a function def square_root(x): assert x >= 0, "Cannot compute square root of negative number" return x ** 0.5 print(square_root(9)) # 3.0 # print(square_root(-1)) # AssertionError

4.3 Understanding AssertionError

When an assertion fails, Python raises an AssertionError. This is a built-in exception that inherits from Exception. You can catch it with try...except, but in practice, you rarely do so because assertions are meant to be used for debugging, not for recoverable error handling.

try: assert 1 == 2, "Math is broken" except AssertionError as e: print(f"Caught: {e}")

However, catching AssertionError is not recommended in production code because it defeats the purpose of assertions as a debugging tool.

5. When to Use assert

5.1 Preconditions: Checking Input to Functions

A precondition is a condition that must be true before a function executes. Assertions are an excellent way to document and enforce preconditions.

def divide(numerator, denominator): assert denominator != 0, "Denominator cannot be zero" return numerator / denominator

5.2 Postconditions: Checking Output from Functions

Postconditions are conditions that must be true after a function returns. You can use assertions to verify that your function produces the expected result.

def square_root(x): assert x >= 0, "x must be non-negative" result = x ** 0.5 assert result >= 0, "Postcondition: square root must be non-negative" return result

5.3 Invariants: Checking Internal State

Invariants are conditions that must always be true for a class or data structure. Assertions can be placed at the beginning and end of methods to ensure the invariant holds.

class BankAccount: def __init__(self, balance): assert balance >= 0, "Initial balance cannot be negative" self.balance = balance def deposit(self, amount): assert amount > 0, "Deposit amount must be positive" self.balance += amount self._check_invariant() def withdraw(self, amount): assert amount > 0, "Withdrawal amount must be positive" assert amount <= self.balance, "Insufficient balance" self.balance -= amount self._check_invariant() def _check_invariant(self): assert self.balance >= 0, "Invariant violated: balance cannot be negative"

5.4 Debugging and Testing

Assertions are great for catching logical errors during development. For example, you might assert that a list is sorted after a sorting function:

def bubble_sort(arr): # ... implementation ... # After sorting, assert that the array is sorted for i in range(len(arr)-1): assert arr[i] <= arr[i+1], "Bubble sort failed to sort the array" return arr

5.5 Documenting Assumptions

Assertions also serve as documentation. They make explicit what the programmer assumes about the state of the program. A reader can see, "Aha, this function expects a non-empty list" by looking at the assert.

6. When NOT to Use assert

6.1 Data Validation from External Sources (User Input, Files, Network)

Assertions can be globally disabled, so you should never use them for validating data that comes from outside your program. If the user enters an invalid age, you should raise a ValueError or use if statements, not an assert.

# Bad: Using assert for user input age = int(input("Enter age: ")) assert age >= 0, "Age cannot be negative" # This may be disabled! # Good: Using raise for user input age = int(input("Enter age: ")) if age < 0: raise ValueError("Age cannot be negative")

6.2 Production Error Handling

Because assertions can be disabled, they should never be relied upon for critical error handling that must always happen, such as security checks, resource cleanup, or business logic validation.

6.3 Logic That Has Side Effects

Assertions are checked only when they are enabled. If your assertion contains a function call that changes program state (a side effect), that change will not happen when assertions are disabled, leading to subtle bugs.

# Dangerous: assertion with side effect assert pop_from_stack() == expected_value, "Popped wrong value" # When assertions are disabled, pop_from_stack() is never called!

Always keep assertions side-effect-free.

6.4 Performance-Critical Code

Even when assertions are enabled, they add overhead. In performance-critical sections, you may want to avoid assertions or disable them in production.

7. Disabling Assertions

Assertions can be globally disabled by running Python with the -O (optimize) or -OO (also removes docstrings) flag.

python -O script.py

When disabled, all assert statements are ignored—they are not executed and the conditions are not evaluated. This means that code inside the condition (including function calls) will not run, which is why side effects in assertions are dangerous.

Note: The -O flag also sets the internal variable __debug__ to False. You can check this variable to conditionally include code:

if __debug__: print("Debug mode enabled") else: print("Optimized mode")

But you almost never need to do this.

8. assert vs raise: A Detailed Comparison

Feature assert raise
Purpose Debugging, testing assumptions during development Runtime error signaling for any situation
Can be disabled Yes, with -O flag No, always active
When to use Conditions that should never happen if code is correct Conditions that can happen due to invalid input, resource issues, business rules
Error type AssertionError Any exception class (ValueError, TypeError, custom, etc.)
Message Optional, but recommended for clarity Required for custom exceptions (or can use default)
Use in production Not recommended for validation Recommended for all validation and error conditions
Documentation Acts as a comment that is checked Acts as a formal error specification

Rule of Thumb:

9. Common Pitfalls and How to Avoid Them

Pitfall Solution
Using assert for user input or validation Use if and raise instead.
Assertion with side effects Keep assertions side-effect-free.
Putting assert in a tuple without parentheses assert (condition, "message") always succeeds because a non-empty tuple is truthy! Use assert condition, "message" (no parentheses).
Relying on assertions for security Assertions can be disabled; use proper checks.
Not providing a message Always include a descriptive message for debugging.
Overusing assertions (everywhere) Use them only for critical assumptions; avoid clutter.

Example of the tuple pitfall:

assert (x > 0, "x must be positive") # This always succeeds because the tuple is truthy! # Correct: assert x > 0, "x must be positive"

10. Real-World Example: A Robust Stack Data Structure

class Stack: def __init__(self, max_size=None): self.items = [] self.max_size = max_size # Invariant: max_size > 0 if specified if max_size is not None: assert max_size > 0, "max_size must be positive" def push(self, item): # Precondition: stack must not be full if max_size is set if self.max_size is not None: assert len(self.items) < self.max_size, "Stack is full" self.items.append(item) def pop(self): # Precondition: stack must not be empty assert len(self.items) > 0, "Cannot pop from empty stack" return self.items.pop() def is_empty(self): return len(self.items) == 0 def __len__(self): return len(self.items)

Testing:

s = Stack(2) s.push(1) s.push(2) # s.push(3) # AssertionError: Stack is full print(s.pop()) # 2 print(s.pop()) # 1 # print(s.pop()) # AssertionError: Cannot pop from empty stack

11. Summary

Concept Description
assert Checks a condition and raises AssertionError if False.
Purpose Debugging aid, not runtime error handling.
Disabling Use -O flag to disable all assertions.
Use cases Preconditions, postconditions, invariants, debugging.
Do NOT use for User input validation, production error handling, security checks.
Best practice Always provide an error message; avoid side effects; document assumptions.

12. Self-Assessment Quiz

Test your understanding of the concepts covered in this tutorial. Answer each question, then click to reveal the correct answer.

Q1: What is the primary purpose of the assert statement in Python?

Answer(B) To test assumptions and catch programming errors during development.

Q2: What happens when an assert condition evaluates to False?

Answer(B) An `AssertionError` is raised.

Q3: True or False: Assertions are always active and cannot be disabled.

AnswerFalse. Assertions can be disabled with the `-O` flag.

Q4: Which of the following is a correct use of assert?

Answer(B) `assert len(numbers) > 0, "List cannot be empty"` is a correct use as a precondition.

Q5: How can you globally disable all assert statements in a Python program?

Answer(B) Use the `-O` flag.

Q6: Which exception is raised when an assertion fails?

Answer(C) `AssertionError`

Q7: What is the output of the following code?

x = 5 assert x == 5, "x is not 5" print("Done")
Answer(A) "Done" is printed because the condition is `True`.

Q8: Which of the following is a pitfall when using assert?

Answer(C) Using it with side effects is a pitfall because the side effect may be skipped when assertions are disabled.

Q9: If you have an assertion with a side effect (e.g., assert pop() == 42), what happens when assertions are disabled?

Answer(B) The side effect is skipped because the entire assertion is ignored.

Q10: Which is more appropriate for checking that a user-provided password meets a length requirement?

Answer(B) `raise` with `ValueError` because user input validation must always be enforced and cannot be disabled.

13. Practical Exercises

Complete the exercises below to reinforce your understanding. Sample solutions are provided after each exercise.

Exercise 1: Adding Assertions to a Function

Instructions: The following function calculate_discount(price, discount_percent) is intended to apply a discount. Add appropriate assert statements to check:

Write the function with these assertions and test it with valid and invalid inputs.

Sample Answer
def calculate_discount(price, discount_percent): # Preconditions assert price >= 0, "Price must be non-negative" assert 0 <= discount_percent <= 100, "Discount percent must be between 0 and 100" final_price = price * (1 - discount_percent / 100) # Postcondition assert final_price >= 0, "Final price must be non-negative" return final_price # Test print(calculate_discount(100, 20)) # 80.0 # print(calculate_discount(-10, 20)) # AssertionError # print(calculate_discount(100, 120)) # AssertionError

Exercise 2: Finding the Bug Using Assertions

Instructions: The following function find_max(nums) is supposed to return the maximum element in a list. However, it contains a bug. Add assertions to check preconditions and postconditions to help identify the bug.

def find_max(nums): max_val = 0 for n in nums: if n > max_val: max_val = n return max_val

Test it with [3, 5, 2] and [-1, -5, -3]. Why does the second test fail? Fix the bug and add appropriate assertions.

Sample Answer
def find_max(nums): # Precondition: list must not be empty assert len(nums) > 0, "List cannot be empty" max_val = nums[0] # Bug fixed: initialize with first element for n in nums: if n > max_val: max_val = n return max_val # Test with assertions # print(find_max([3, 5, 2])) # 5 # print(find_max([-1, -5, -3])) # -1

Explanation: The bug was initializing max_val = 0, which fails for negative numbers. The assertion on the empty list would catch an empty input.

Exercise 3: Assertions in a Class

Instructions: Write a class Temperature that represents a temperature in Celsius. It should:

Test the class with valid and invalid temperatures.

Sample Answer
class Temperature: ABSOLUTE_ZERO = -273.15 def __init__(self, celsius): assert celsius >= Temperature.ABSOLUTE_ZERO, f"Temperature cannot be below absolute zero ({Temperature.ABSOLUTE_ZERO}°C)" self.celsius = celsius def to_fahrenheit(self): return self.celsius * 9/5 + 32 def to_kelvin(self): kelvin = self.celsius - Temperature.ABSOLUTE_ZERO # Postcondition: Kelvin must be >= 0 assert kelvin >= 0, "Kelvin temperature cannot be negative" return kelvin # Test t = Temperature(25) print(t.to_fahrenheit()) # 77.0 print(t.to_kelvin()) # 298.15 # t = Temperature(-300) # AssertionError

Exercise 4: Assertions vs Raise

Instructions: Rewrite the following code using raise instead of assert where appropriate, and keep assert only where it is used for debugging.

def process_data(data): assert data is not None, "Data cannot be None" # For debugging assert len(data) > 0, "Data must not be empty" # For debugging # Validate user input (should always be checked) assert all(isinstance(x, int) for x in data), "All elements must be integers" # User input? # Some processing... return sum(data) / len(data)

Explain your reasoning.

Sample Answer
def process_data(data): # Preconditions for debugging (programmer errors) assert data is not None, "Data cannot be None" assert len(data) > 0, "Data must not be empty" # User input validation - must always be enforced if not all(isinstance(x, (int, float)) for x in data): raise TypeError("All elements must be numbers") return sum(data) / len(data) # Explanation: The first two are checking assumptions about the caller's code, # which should never be violated if the caller is correct. The third is checking # the nature of the data itself, which could be from an external source and must # be validated at runtime.

Exercise 5: Disabling Assertions

Instructions: Write a program that uses an assert statement to check a condition. Run it normally, then run it with python -O to disable assertions. Observe the difference. Write a comment in your code explaining what you observed.

Sample Answer
# save as test_assert.py def test_assertion(): x = 10 assert x == 5, "x should be 5" print("This line is not printed if assertion fails") test_assertion()

Run:

python test_assert.py # Raises AssertionError python -O test_assert.py # No assertion error, prints "This line..."

Comment in code: "When run with -O, the assertion is disabled, so the program continues as if the condition were true. This is useful for performance-critical production code where we trust our logic."

14. Homework Questions

Short Answer Questions

1. You are tasked with implementing a function safe_divide(a, b) that:

Write the function and discuss why using assert here is appropriate. Then, provide a second version of the function that also uses raise for b == 0 and explain why that might be more flexible.

Sample Answer
import math # Version 1: Using assert (assuming internal use) def safe_divide_assert(a, b): assert b != 0, "Denominator cannot be zero" result = a / b assert math.isfinite(result), "Result must be finite" return result # Version 2: Using raise (more flexible) def safe_divide_raise(a, b): if b == 0: raise ValueError("Denominator cannot be zero") result = a / b if not math.isfinite(result): raise RuntimeError("Result is not finite") return result

Discussion: Version 1 uses assert because it assumes the function is called only from internal code that should never pass zero. It's a debugging aid. Version 2 uses raise because it can be used in production with external input; it always checks the condition.

2. Examine the following code snippets. For each, determine whether the use of assert is appropriate. If not, suggest a better alternative.

a)

def load_configuration(file_path): with open(file_path, 'r') as f: content = f.read() config = json.loads(content) assert 'database' in config, "Missing 'database' key in config" return config

b)

def process_payment(amount, balance): assert amount > 0, "Amount must be positive" assert amount <= balance, "Insufficient balance" balance -= amount return balance

c)

def sort_list(lst): assert isinstance(lst, list), "Input must be a list" lst.sort() assert lst == sorted(lst), "Sorting failed" return lst

d)

def connect_to_server(server): assert ping(server), "Server is unreachable" # ping() is a function that changes state # Connect...
Sample Answer

a) Inappropriate. The config file is external; missing keys should be handled with raise (e.g., KeyError or custom exception). Use raise instead.

b) Partially appropriate. amount > 0 is a business rule that should always be enforced, so use raise ValueError. amount <= balance is also a business rule; use raise RuntimeError. Assertions are not appropriate here.

c) Appropriate. The first assert is a precondition that the caller should guarantee (type check). The second assert is a postcondition to catch sorting bugs. However, the type check could be considered validation if input comes from outside; but if it's internal, it's fine.

d) Inappropriate. ping(server) likely has side effects (network calls) and is a runtime check. If assertions are disabled, the connection attempt proceeds without checking reachability. Use raise with explicit validation.

3. Write a custom function my_assert(condition, message) that behaves like assert but does not use the built-in assert statement. It should:

Use this function instead of assert in a small program. Discuss the advantages and disadvantages compared to the built-in assert.

Sample Answer
ENABLE_ASSERT = True def my_assert(condition, message): if ENABLE_ASSERT: if not condition: raise AssertionError(message) # Usage x = 5 my_assert(x == 10, "x should be 10")

Advantages:

Disadvantages:

Essay Question

4. Write a program that measures the execution time of a function with and without assertions. Use the timeit module. The function should perform a computation (e.g., summing a large list) with assertions inside. Compare the runtime with assertions enabled and disabled (using -O). Write a report summarizing your findings.

Sample Answer
import timeit def sum_with_assertions(lst): total = 0 for x in lst: assert x > 0, "Positive only" total += x return total def sum_without_assertions(lst): total = 0 for x in lst: total += x return total lst = list(range(1000000)) # Measure with assertions enabled (run as normal) # timeit.timeit('sum_with_assertions(lst)', globals=globals(), number=10) # Measure with assertions disabled: run with python -O and import the functions.

Expected findings: Assertions add overhead; with -O the speed is closer to the version without assertions. The performance impact depends on the frequency of assertions and the complexity of the condition being checked.

Research Question

5. Discuss the role of assertions in the software development lifecycle. Include:

Provide examples from real-world programming languages to support your arguments.

Sample Answer

Assertions play a vital role in the software development lifecycle, serving as a first line of defense against logical errors. During development and testing, assertions help catch bugs early by validating assumptions that programmers make about the state of the program. They act as executable documentation, making explicit what the programmer expects to be true at certain points in the code. For example, a function that computes the square root might assert that its input is non-negative, immediately catching a bug if a negative value is passed during testing.

The relationship between assertions and unit tests is complementary. Unit tests verify the external behavior of functions, while assertions verify internal assumptions. A well-designed unit test suite can catch many bugs, but assertions provide additional safety by checking conditions that might not be covered by tests. For instance, an assertion that a list is sorted after a sorting function can catch subtle bugs that might not be detected by simple input-output tests.

Assertions are often turned off in production to improve performance. In performance-critical applications, the overhead of checking assertions can be significant, especially if they are placed inside loops. Additionally, once software is thoroughly tested, the probability of assertion failures is low, so disabling them is considered safe.

However, there are potential dangers in relying on assertions for correctness. If assertions are used for input validation or security checks, disabling them can expose the application to vulnerabilities. For example, if an assertion is used to check that a user has the appropriate permissions, disabling assertions would bypass the security check. Therefore, assertions should never be used for security-critical or input validation tasks.

My opinion is that assertions should be used liberally during development and testing, but disabled in production for performance. However, critical validation should always be implemented using raise statements that are never disabled. This balanced approach ensures that bugs are caught early in development while maintaining performance and security in production. Other languages like C, C++, and Java have similar assert mechanisms with the ability to disable them, reflecting a widely accepted best practice.

This tutorial provides a comprehensive understanding of the assert statement, its appropriate use cases, and its limitations. The quizzes, exercises, and homework problems will help reinforce the concepts and distinguish assert from raise. Happy coding!

Previous | Tutorial index | Next