Difficulty: Intermediate
The assert statement is a debugging aid that tests a condition and raises AssertionError if the condition is False. Its syntax is assert condition, 'optional message'. Assertions document your assumptions about the program state at specific points in the code. They are not meant for validating user input or handling expected errors -- they exist to catch programming bugs during development.
A key characteristic of assert is that it can be disabled. When Python runs with the -O (optimize) flag, all assert statements are completely removed from the bytecode. The global variable __debug__ is True under normal execution and False under -O. This means you should never use assertions for anything that must always be checked, such as input validation, security checks, or data integrity verification. Assertions are strictly for catching bugs that should never occur if the code is correct.
The traceback module provides tools for working with Python's exception tracebacks programmatically. When an exception occurs, Python creates a traceback object that records the chain of function calls leading to the error. The traceback module lets you format, print, or extract information from these tracebacks. Common functions include traceback.print_exc() to print the current exception's traceback, traceback.format_exc() to get it as a string, and traceback.print_stack() to print the current call stack without an exception.
Effective debugging in Python combines several techniques. Print debugging -- strategically placing print() calls to inspect variable values and execution flow -- remains one of the most practical approaches. The built-in breakpoint() function (Python 3.7+) drops you into the interactive debugger (pdb) at that point. The repr() function shows the precise representation of a value, which is more useful than str() for debugging because it reveals the type and exact content, including hidden characters.
Defensive programming complements error handling by checking preconditions and postconditions. Preconditions validate that a function's inputs meet requirements before processing begins. Postconditions verify that the result makes sense before returning it. While input validation should use if/raise for user-facing code, assertions are appropriate for internal invariants: conditions that should always hold true if your code is correct. For example, asserting that a list is sorted after calling your sort function catches bugs without adding runtime overhead in production.
def calculate_average(numbers):
assert len(numbers) > 0, "Cannot average an empty list"
assert all(isinstance(n, (int, float)) for n in numbers), "All items must be numbers"
total = sum(numbers)
avg = total / len(numbers)
assert isinstance(avg, float), "Average should be a float"
return avg
try:
result = calculate_average([10, 20, 30])
print(f"Average: {result}")
except AssertionError as e:
print(f"Assertion failed: {e}")
try:
calculate_average([])
except AssertionError as e:
print(f"Assertion failed: {e}")
try:
calculate_average([1, 'two', 3])
except AssertionError as e:
print(f"Assertion failed: {e}")
Assertions check preconditions (non-empty list, all numeric) and a postcondition (result is float). Each assertion includes a descriptive message. Remember: assertions should catch programmer errors, not validate user input.
print(f"Debug mode: {__debug__}")
def process(value):
if __debug__:
print(f" Debug: processing {value!r}")
result = value * 2
assert result > 0, f"Expected positive result, got {result}"
return result
try:
print(f"Result: {process(5)}")
print(f"Result: {process(-3)}")
except AssertionError as e:
print(f"Assertion failed: {e}")
__debug__ is True in normal execution. Both debug print statements and assertions run. With python -O, __debug__ becomes False, debug prints are skipped, and assertions are removed entirely.
import traceback
def inner():
raise ValueError("something broke")
def middle():
inner()
def outer():
try:
middle()
except ValueError:
tb_string = traceback.format_exc()
lines = tb_string.strip().split('\n')
print(f"Error chain has {len(lines)} lines")
print(f"First line: {lines[0]}")
print(f"Last line: {lines[-1]}")
outer()
traceback.format_exc() captures the full traceback as a string. We split it into lines to analyze the call chain. The first line is always 'Traceback (most recent call last):' and the last line shows the exception type and message.
def debug_values(*args):
for i, val in enumerate(args):
print(f" arg[{i}]: type={type(val).__name__}, repr={val!r}")
def tricky_comparison():
a = "hello"
b = "hello "
print(f"Strings look equal: '{a}' vs '{b}'")
print(f"Are they equal? {a == b}")
debug_values(a, b)
print(f"Lengths: {len(a)} vs {len(b)}")
tricky_comparison()
print("---")
debug_values(0, False, "", None, [], 0.0)
print("All are falsy:", all(not v for v in [0, False, "", None, [], 0.0]))
Using repr (via !r in f-strings) reveals hidden details like trailing spaces. The debug_values function is a reusable tool that shows both type and repr of any value, invaluable for finding subtle bugs like confusing 0 with False or empty string with None.
assert, __debug__, debugging techniques, traceback, defensive programming