Assertions and Invariants - Encoding Correctness in Code
Reading time: ~16 minutes | Level: Foundation → Engineering
Here is a fact that surprises many Python developers:
# Run this normally: python script.py
# Run optimised: python -O script.py
def process_payment(amount):
assert amount > 0, "Payment amount must be positive"
return charge_card(amount)
process_payment(-50) # Raises AssertionError normally
# SILENTLY DOES NOTHING under python -O
The assert statement is disabled entirely when Python is run with the -O (optimize) flag. It is as if the line does not exist. If you use assert to validate user input or enforce security rules, those checks vanish in optimised deployments.
This single fact separates developers who use assertions correctly from those who create subtle production bugs.
What You Will Learn
- What the
assertstatement does and how it compiles to bytecode - Why assertions are disabled under
python -Oand what that means for production code - The critical rule: never use
assertfor input validation or security checks - The correct role of assertions: documenting and enforcing invariants during development
- Class invariants - properties that must always be true about an object
- Loop invariants - assertions that hold before and after each iteration
- Pre-conditions and post-conditions (Design by Contract)
assert isinstance(x, T)as a type narrowing tool for type checkers- Real-world assertion patterns in ML pipelines and recursive algorithms
Prerequisites
- Python try/except and exception basics (topics 01–04 of this module)
- Python classes and
__init__(basic OOP) - Understanding of Python function definitions and return values
Part 1 - What assert Actually Does
Syntax
assert condition
assert condition, message
assert condition raises AssertionError if condition is falsy. assert condition, message includes message in the AssertionError.
x = -5
assert x > 0 # AssertionError (no message)
assert x > 0, f"Expected positive, got {x}" # AssertionError: Expected positive, got -5
How It Compiles
The assert statement is syntactic sugar. CPython compiles:
assert condition, message
to roughly:
if __debug__:
if not condition:
raise AssertionError(message)
__debug__ is a built-in constant that is True by default and False when Python is run with -O. When __debug__ is False, the entire if block is optimised away at the bytecode level - the condition is never even evaluated.
You can verify this yourself:
print(__debug__) # True in normal mode, False under -O
Part 2 - The Cardinal Rule: Never Assert Input Validation
This is the most important rule in this entire topic:
:::danger Never Use assert for Production Input Validation
assert is for invariants you control - properties that should never be false if your code is correct. It is NOT for validating external data (user input, API responses, file contents) because assertions are disabled in optimised deployments and can be bypassed entirely.
:::
Wrong - Using assert for Input Validation
# BAD: Using assert to validate user-provided input
def create_account(username: str, age: int):
assert isinstance(username, str), "username must be a string"
assert len(username) >= 3, "username must be at least 3 characters"
assert age >= 18, "user must be 18 or older"
# ... create account
Problems:
- Under
python -O, all three asserts disappear. A 10-year-old user with a 1-character username can create an account. AssertionErroris not a meaningful error type for callers to catch - they should get aValueErroror a domain-specific exception.- Callers who write
except ValueErrorto handle bad input will miss theAssertionError.
Correct - Using if/raise for Input Validation
# GOOD: Explicit validation with informative exceptions
def create_account(username: str, age: int):
if not isinstance(username, str):
raise TypeError(f"username must be str, got {type(username).__name__}")
if len(username) < 3:
raise ValueError(f"username must be at least 3 characters, got {len(username)!r}")
if age < 18:
raise ValueError(f"user must be 18 or older, got age={age}")
# ... create account
This validation always runs, raises a meaningful exception type, and gives callers a contract they can rely on.
When assert IS Correct
# GOOD: Asserting a property that should be guaranteed by YOUR code
def binary_search(sorted_list, target):
# This is an invariant about OUR data structure, not user input
assert sorted_list == sorted(sorted_list), "binary_search requires a sorted list"
left, right = 0, len(sorted_list) - 1
# ...
Even this example has nuance - if sorted_list is user-provided data, the assert is wrong. But if it is an internal list that your code constructs and guarantees is sorted, the assert is documenting that internal guarantee.
Part 3 - assert vs if/raise: The Decision Framework
Decision questions:
- Could this condition be false due to a caller or user providing unexpected input? → Yes: use
if/raise- No: it's an internal invariant - Should this check survive in a production deployment? → Yes: use
if/raise- No:assertis acceptable (development/testing only)
Use assert | Use if/raise |
|---|---|
| Internal invariant | User input validation |
| Unreachable code paths | API request validation |
| Post-condition checks | Configuration validation |
| Development debugging | Business rule enforcement |
| Test helper assertions | Security checks |
Code Comparison
# CASE 1: Marking unreachable branches - assert is correct
def get_direction(angle: float) -> str:
if 0 <= angle < 90:
return "NE"
elif 90 <= angle < 180:
return "SE"
elif 180 <= angle < 270:
return "SW"
elif 270 <= angle < 360:
return "NW"
else:
# This branch is unreachable IF angle is pre-validated
# Assert documents that this should never happen
assert False, f"Unreachable: angle={angle} should have been validated before calling this"
# CASE 2: Validating function pre-conditions for internal functions
# (called only from code you control, not by users)
def _merge_sorted_halves(left: list, right: list) -> list:
# Document that both halves must be sorted - a contract between
# internal callers and this private function
assert left == sorted(left), "_merge_sorted_halves: left must be sorted"
assert right == sorted(right), "_merge_sorted_halves: right must be sorted"
# ... merge logic
# CASE 3: REST API handler - assert is WRONG, if/raise is right
# This receives user-controlled data
from fastapi import HTTPException
@app.post("/payments/")
def create_payment(amount: float):
# WRONG:
# assert amount > 0, "amount must be positive"
# CORRECT:
if amount <= 0:
raise HTTPException(status_code=422, detail="amount must be positive")
# ... process payment
Part 4 - Assertions as Executable Documentation
The deepest value of assert is that it makes invariants part of the code itself, not just comments.
Comments rot. A comment that says # result is always non-empty here can become false as code evolves. An assert len(result) > 0 will scream if the invariant breaks during development.
def top_k_results(candidates: list, k: int) -> list:
"""Return the top-k candidates by score."""
if not candidates:
return []
scored = [(score(c), c) for c in candidates]
scored.sort(reverse=True)
result = scored[:k]
# This assertion documents a post-condition:
# IF candidates was non-empty and k > 0, result must also be non-empty.
# If this fires, something in the algorithm above is broken.
assert len(result) > 0, (
f"top_k_results produced empty result for {len(candidates)} candidates "
f"with k={k} - algorithm error"
)
assert len(result) <= k, (
f"top_k_results produced {len(result)} results, expected at most {k}"
)
return [c for _, c in result]
If a future refactor breaks the slicing logic, the assertion fires immediately in testing rather than silently returning wrong data that manifests as a bug 10 layers up the call stack.
Part 5 - Class Invariants
A class invariant is a condition that must hold true for every valid instance of the class, from after __init__ returns to just before __del__ is called.
Checking Invariants in __init__
class BoundedCounter:
"""A counter that stays within [min_val, max_val]."""
def __init__(self, initial: int, min_val: int, max_val: int):
if min_val > max_val:
raise ValueError(f"min_val ({min_val}) must be <= max_val ({max_val})")
if not (min_val <= initial <= max_val):
raise ValueError(
f"initial ({initial}) must be in [{min_val}, {max_val}]"
)
self._count = initial
self._min = min_val
self._max = max_val
# Assert the invariant immediately after construction
self._check_invariant()
def _check_invariant(self):
"""Assert all class invariants. Called at start and end of mutations."""
assert self._min <= self._count <= self._max, (
f"BoundedCounter invariant violated: "
f"count={self._count} not in [{self._min}, {self._max}]"
)
def increment(self, by: int = 1):
self._check_invariant() # Pre-condition: invariant holds on entry
if self._count + by > self._max:
raise ValueError(f"Cannot increment: would exceed max {self._max}")
self._count += by
self._check_invariant() # Post-condition: invariant still holds
def decrement(self, by: int = 1):
self._check_invariant()
if self._count - by < self._min:
raise ValueError(f"Cannot decrement: would go below min {self._min}")
self._count -= by
self._check_invariant()
@property
def value(self) -> int:
return self._count
c = BoundedCounter(initial=5, min_val=0, max_val=10)
c.increment(3)
print(c.value) # 8
c.increment(3) # Raises ValueError: Cannot increment: would exceed max 10
# The assertion in _check_invariant would only fire if there was
# a logic bug in increment/decrement - not for normal use
:::note Performance of Invariant Checks
_check_invariant() is called on every mutation. In production this can be expensive. A common pattern is to only call it in debug mode: if __debug__: self._check_invariant(). Under -O, the call is still made but the assert inside is a no-op - for full elimination, structure it as assert self._check_invariant_condition() where the method returns a bool.
:::
A Cleaner Pattern: Property Validation
For many classes, @property setters provide a better mechanism than invariant checking methods:
class BoundedCounter:
def __init__(self, initial: int, min_val: int, max_val: int):
self._min = min_val
self._max = max_val
self.value = initial # Goes through the setter
@property
def value(self) -> int:
return self._count
@value.setter
def value(self, new_value: int):
if not (self._min <= new_value <= self._max):
raise ValueError(
f"Value {new_value} out of range [{self._min}, {self._max}]"
)
self._count = new_value
This enforces the invariant every time value is set, using if/raise (always enforced) rather than assert (disabled under -O). Use this pattern when the invariant must hold in production.
Part 6 - Loop Invariants
A loop invariant is a condition that is true before the loop starts and remains true after each iteration. Loop invariants are the formal basis for proving loop correctness.
Manual Binary Search with Invariant Assertions
def binary_search(arr: list, target) -> int:
"""Return index of target in sorted arr, or -1 if not found."""
assert arr == sorted(arr), "binary_search requires a sorted array"
left, right = 0, len(arr) - 1
while left <= right:
# LOOP INVARIANT: if target is in arr, it is in arr[left:right+1]
assert 0 <= left <= right + 1 <= len(arr), (
f"Invariant violated: left={left}, right={right}, len={len(arr)}"
)
mid = left + (right - left) // 2 # Avoids overflow vs (left+right)//2
if arr[mid] == target:
# Post-condition: arr[mid] == target
assert arr[mid] == target
return mid
elif arr[mid] < target:
left = mid + 1
else:
right = mid - 1
# Post-condition: target not in arr
assert target not in arr, (
f"Search ended without finding {target!r} but it IS in the array"
)
return -1
data = [2, 5, 8, 12, 16, 23, 38, 56, 72, 91]
print(binary_search(data, 23)) # 5
print(binary_search(data, 10)) # -1
The loop invariant assertions serve two purposes:
- Documentation: they state precisely what the algorithm maintains
- Verification: during development and testing they fire immediately if the logic breaks
Invariant Assertions in Sorting Algorithms
def insertion_sort(arr: list) -> list:
"""Sort in-place using insertion sort. O(n^2)."""
arr = arr.copy()
n = len(arr)
for i in range(1, n):
# INVARIANT: arr[0:i] is sorted before each outer iteration
assert arr[:i] == sorted(arr[:i]), (
f"Insertion sort invariant broken at i={i}: arr[:{i}]={arr[:i]}"
)
key = arr[i]
j = i - 1
while j >= 0 and arr[j] > key:
arr[j + 1] = arr[j]
j -= 1
arr[j + 1] = key
# POST-CONDITION: entire array is sorted
assert arr == sorted(arr), "Insertion sort failed to sort the array"
return arr
Part 7 - Pre-conditions and Post-conditions (Design by Contract)
Design by Contract (DbC) is a programming methodology that specifies functions by their:
- Pre-conditions: what the caller must guarantee before calling
- Post-conditions: what the function guarantees after returning
- Invariants: what remains true throughout
Python does not have native DbC support, but assert provides it informally:
def compute_discount(price: float, discount_pct: float) -> float:
"""Compute the discounted price.
Pre-conditions (caller's responsibility):
- price > 0
- 0 <= discount_pct <= 100
Post-conditions (this function's guarantee):
- result >= 0
- result <= price
"""
# PRE-CONDITIONS - document what callers must provide
assert price > 0, f"Pre-condition violated: price must be positive, got {price}"
assert 0 <= discount_pct <= 100, (
f"Pre-condition violated: discount_pct must be in [0, 100], got {discount_pct}"
)
result = price * (1 - discount_pct / 100)
# POST-CONDITIONS - document what we guarantee
assert result >= 0, f"Post-condition violated: result is negative ({result})"
assert result <= price, (
f"Post-condition violated: discounted price ({result}) exceeds "
f"original price ({price})"
)
return result
print(compute_discount(100.0, 20.0)) # 80.0
print(compute_discount(100.0, 0.0)) # 100.0
print(compute_discount(100.0, 100.0)) # 0.0
# compute_discount(-10, 20) # AssertionError: Pre-condition violated
# compute_discount(100, 150) # AssertionError: Pre-condition violated
:::tip Pre-conditions vs Input Validation
When using DbC-style pre-condition assertions, document clearly in the docstring that the caller is responsible for satisfying them. If the function is called by external/user-controlled code, replace the assertions with if/raise for the reasons discussed in Part 2.
:::
Part 8 - Type Narrowing Assertions
Python's type system (mypy, Pyright) cannot always infer types precisely. Assertions help type checkers understand what type a value is at a specific point in the code:
from typing import Optional
def process_name(value: Optional[str]) -> str:
# Without this assertion, the type checker sees `value` as `Optional[str]`
# and will warn about calling `.upper()` on a potentially-None value
assert value is not None, "process_name requires a non-None value"
# After the assertion, the type checker KNOWS value is `str`
# (because if it were None, the assertion would have raised)
return value.upper() # No type error - type narrowed to str
This is particularly useful with union types:
from typing import Union
import json
def parse_config(raw: Union[str, dict]) -> dict:
if isinstance(raw, str):
result = json.loads(raw)
assert isinstance(result, dict), (
f"Config JSON must be an object, got {type(result).__name__}"
)
return result
else:
assert isinstance(raw, dict) # Type narrowing for type checker
return raw
The assert isinstance(x, T) pattern is explicitly recognised by mypy and Pyright as a type guard - they narrow the type of x to T in the code that follows.
Part 9 - Common Assertion Patterns
Pattern 1: asserting non-empty collections
def compute_average(values: list) -> float:
assert len(values) > 0, "Cannot compute average of empty list"
return sum(values) / len(values)
Pattern 2: asserting type (for internal/development use)
def _process_batch(records: list):
assert all(isinstance(r, dict) for r in records), (
"All records in batch must be dicts"
)
# ... process
Pattern 3: asserting mutual exclusion
def apply_filter(data, *, include=None, exclude=None):
assert not (include and exclude), (
"Cannot specify both include and exclude - choose one"
)
# ...
Pattern 4: asserting state machine transitions
VALID_TRANSITIONS = {
"pending": {"processing", "cancelled"},
"processing": {"completed", "failed"},
"completed": set(),
"failed": {"pending"},
"cancelled": set(),
}
class Order:
def __init__(self):
self.status = "pending"
def transition_to(self, new_status: str):
assert new_status in VALID_TRANSITIONS[self.status], (
f"Invalid transition: {self.status!r} -> {new_status!r}. "
f"Valid transitions: {VALID_TRANSITIONS[self.status]}"
)
self.status = new_status
Pattern 5: asserting mathematical properties
def normalise(probabilities: list) -> list:
assert all(p >= 0 for p in probabilities), "All probabilities must be non-negative"
total = sum(probabilities)
assert total > 0, "Sum of probabilities must be positive"
result = [p / total for p in probabilities]
# Post-condition: normalised probabilities sum to 1.0
assert abs(sum(result) - 1.0) < 1e-9, (
f"Normalisation failed: sum = {sum(result)}"
)
return result
Part 10 - Real-World: Assertions in ML Pipelines
ML pipelines are full of implicit assumptions about data shape, dtype, and value ranges. Making these explicit with assertions catches data bugs early - before they silently corrupt model training.
import numpy as np
def preprocess_features(X: np.ndarray, y: np.ndarray) -> tuple:
"""Preprocess feature matrix and target vector for model training."""
# Shape invariants
assert X.ndim == 2, f"X must be 2D, got shape {X.shape}"
assert y.ndim == 1, f"y must be 1D, got shape {y.shape}"
assert X.shape[0] == y.shape[0], (
f"X and y must have same number of samples: "
f"X has {X.shape[0]}, y has {y.shape[0]}"
)
# Data quality invariants
assert not np.any(np.isnan(X)), "X contains NaN values"
assert not np.any(np.isinf(X)), "X contains infinite values"
# Label validity
unique_labels = np.unique(y)
assert len(unique_labels) >= 2, (
f"Need at least 2 classes for classification, got: {unique_labels}"
)
# Normalise features
mean = X.mean(axis=0)
std = X.std(axis=0)
# Post-condition: no zero-std columns (would cause division by zero)
assert not np.any(std == 0), (
f"Zero standard deviation in columns: {np.where(std == 0)[0]}"
)
X_normalised = (X - mean) / std
# Post-condition: normalised features are well-behaved
assert not np.any(np.isnan(X_normalised)), "Normalisation produced NaN"
assert not np.any(np.isinf(X_normalised)), "Normalisation produced inf"
return X_normalised, y
def train_model(X: np.ndarray, y: np.ndarray, test_X: np.ndarray):
"""Train a model. Asserts data contracts between train and test sets."""
assert X.shape[1] == test_X.shape[1], (
f"Feature mismatch: train has {X.shape[1]} features, "
f"test has {test_X.shape[1]}"
)
# ... training code
These assertions are disabled in a production scoring service (where you would use if/raise instead), but during the training pipeline they act as fast, zero-cost documentation of data contracts that break immediately when violated.
Interview Questions
Q1: What does Python's -O flag do to assert statements?
Answer: The -O (optimize) flag sets __debug__ to False at compile time. The Python compiler then removes every assert statement from the generated bytecode - they are not executed and the condition is never evaluated. This means assert is not a reliable mechanism for any check that must run in production. Gunicorn and other WSGI servers sometimes run with -O, as do some container deployments. The practical consequence: assert is for development invariants only. Anything that must hold in production must use if/raise.
Q2: What is the difference between using assert and if/raise for validating function arguments?
Answer: assert should only be used for internal invariants - conditions that reflect programmer errors and that you expect to never be false if the code is correct. It is disabled under -O. if/raise is for all input validation: user-provided data, external API responses, configuration files, and any condition that a legitimate caller could trigger through normal use. if/raise also gives you control over the exception type - you can raise ValueError, TypeError, or a domain-specific exception that callers can catch meaningfully. AssertionError is not part of any public API contract.
Q3: What is a class invariant and how do you enforce it in Python?
Answer: A class invariant is a condition that must hold true for every valid instance of the class throughout its lifetime. In Python, invariants can be enforced with: (1) validation in __init__ using if/raise for conditions that must hold in production, (2) _check_invariant() helper called at the start and end of mutation methods using assert for development-time checking, and (3) @property setters that validate on every assignment. The production-safe approach uses if/raise in property setters and __init__. The development-time approach adds assert checks in mutation methods. For critical invariants, use if/raise in both.
Q4: What is a loop invariant and why is it useful?
Answer: A loop invariant is a condition that is true before the loop starts and remains true after every iteration. It defines what the loop maintains as it runs. Loop invariants are the foundation for proving loop correctness - if the invariant holds at entry and is maintained by every iteration, then it holds when the loop exits, and combining this with the loop termination condition gives you the post-condition. In Python, invariants can be written as assert statements at the start of each loop body. They serve as executable documentation during development: if the algorithm breaks the invariant, the assertion fires immediately rather than producing subtle incorrect output.
Q5: What is Design by Contract and how do assertions implement it in Python?
Answer: Design by Contract (DbC), introduced by Bertrand Meyer, specifies functions by three properties: pre-conditions (what the caller must guarantee), post-conditions (what the function guarantees on return), and invariants (what remains true throughout). In Python, assert provides an informal implementation: pre-conditions are asserted at the start of the function, post-conditions before the return, and class invariants in _check_invariant(). The limitation is that assertions are disabled under -O, so DbC in Python is primarily a development and testing tool. For production contracts, use if/raise. Some libraries (icontract, deal) provide decorator-based DbC that survives optimization.
Q6: When is assert isinstance(x, T) useful beyond runtime checking?
Answer: Beyond the runtime check, assert isinstance(x, T) serves as a type narrowing hint for static type checkers like mypy and Pyright. After the assertion, the type checker understands that x is of type T (because if it were not, the assertion would have raised AssertionError). This allows you to call methods on x that are only available on T without type errors. This is particularly useful with Optional[T] values - after assert x is not None, the type checker narrows x from Optional[T] to T. For production code where the type narrowing is important but the check must run, use if not isinstance(x, T): raise TypeError(...) instead.
Practice Challenges
Beginner - Fix the Assertion Misuse
Problem: The following function uses assert incorrectly for input validation. Identify all misuses and rewrite the function using proper validation.
def divide(numerator, denominator):
assert isinstance(numerator, (int, float)), "numerator must be numeric"
assert isinstance(denominator, (int, float)), "denominator must be numeric"
assert denominator != 0, "cannot divide by zero"
return numerator / denominator
Solution
def divide(numerator, denominator):
"""Divide numerator by denominator.
Args:
numerator: The dividend (int or float).
denominator: The divisor (int or float, non-zero).
Returns:
The result as a float.
Raises:
TypeError: If either argument is not numeric.
ZeroDivisionError: If denominator is zero.
"""
if not isinstance(numerator, (int, float)):
raise TypeError(
f"numerator must be int or float, got {type(numerator).__name__}"
)
if not isinstance(denominator, (int, float)):
raise TypeError(
f"denominator must be int or float, got {type(denominator).__name__}"
)
if denominator == 0:
raise ZeroDivisionError(
f"Cannot divide {numerator} by zero"
)
return numerator / denominator
# Test
print(divide(10, 4)) # 2.5
print(divide(7.5, 2.5)) # 3.0
try:
divide(10, 0)
except ZeroDivisionError as e:
print(f"ZeroDivisionError: {e}") # Cannot divide 10 by zero
try:
divide("ten", 2)
except TypeError as e:
print(f"TypeError: {e}") # numerator must be int or float, got str
# Why the original was wrong:
# 1. All three checks are disabled under python -O
# 2. AssertionError is not the right exception type for callers to catch
# 3. A REST API handler would never see these errors if the deployment
# uses optimised Python
Intermediate - Implement a Stack with Class Invariants
Problem: Implement a BoundedStack class that holds at most max_size elements. Use if/raise for user-facing validation and assert for internal invariants. Add a _check_invariant() method that asserts all structural invariants and call it at the start and end of every mutation.
Solution
class BoundedStack:
"""A stack with a maximum capacity.
Class invariants (always true for a valid BoundedStack):
1. 0 <= len(self._items) <= self._max_size
2. self._max_size >= 1
"""
def __init__(self, max_size: int):
# Input validation (always runs, even under -O)
if not isinstance(max_size, int):
raise TypeError(f"max_size must be int, got {type(max_size).__name__}")
if max_size < 1:
raise ValueError(f"max_size must be >= 1, got {max_size}")
self._max_size = max_size
self._items = []
# Assert invariant holds after construction
self._check_invariant()
def _check_invariant(self):
"""Assert all class invariants. Should hold after every operation."""
assert isinstance(self._items, list), "Internal: _items must be a list"
assert self._max_size >= 1, "Internal: _max_size must be >= 1"
assert 0 <= len(self._items) <= self._max_size, (
f"Internal: size {len(self._items)} out of range [0, {self._max_size}]"
)
def push(self, item) -> None:
"""Push an item onto the stack.
Raises:
OverflowError: If the stack is full.
"""
self._check_invariant() # Pre: invariant holds on entry
if len(self._items) >= self._max_size:
raise OverflowError(
f"Stack is full (max_size={self._max_size})"
)
self._items.append(item)
self._check_invariant() # Post: invariant still holds
def pop(self):
"""Pop and return the top item.
Raises:
IndexError: If the stack is empty.
"""
self._check_invariant()
if not self._items:
raise IndexError("pop from empty stack")
item = self._items.pop()
self._check_invariant()
return item
def peek(self):
"""Return the top item without removing it.
Raises:
IndexError: If the stack is empty.
"""
if not self._items:
raise IndexError("peek at empty stack")
return self._items[-1]
@property
def size(self) -> int:
return len(self._items)
@property
def is_empty(self) -> bool:
return len(self._items) == 0
@property
def is_full(self) -> bool:
return len(self._items) >= self._max_size
def __repr__(self):
return f"BoundedStack(max_size={self._max_size}, items={self._items})"
# Test the stack
stack = BoundedStack(max_size=3)
print(f"Empty: {stack.is_empty}") # Empty: True
stack.push("first")
stack.push("second")
stack.push("third")
print(f"Full: {stack.is_full}") # Full: True
print(f"Peek: {stack.peek()}") # Peek: third
try:
stack.push("fourth")
except OverflowError as e:
print(f"OverflowError: {e}") # OverflowError: Stack is full (max_size=3)
print(f"Popped: {stack.pop()}") # Popped: third
print(f"Size: {stack.size}") # Size: 2
print(stack) # BoundedStack(max_size=3, items=['first', 'second'])
Advanced - Assert-Verified Recursive Algorithm
Problem: Implement merge sort with loop invariant and pre/post-condition assertions at each recursive level. The assertions should verify: (1) the input is a list, (2) each sub-array passed to merge is sorted, and (3) the merged result is sorted and has the same elements as the input.
Solution
def merge_sort(arr: list) -> list:
"""Sort a list using merge sort. Returns a new sorted list.
Pre-conditions:
- arr is a list
Post-conditions:
- result is sorted
- result contains the same elements as arr (same multiset)
"""
# Pre-condition check
assert isinstance(arr, list), f"merge_sort requires a list, got {type(arr).__name__}"
# Base case
if len(arr) <= 1:
result = arr.copy()
# Post-condition holds trivially for 0 or 1 elements
assert result == sorted(result), "Base case post-condition: single element not sorted"
return result
# Divide
mid = len(arr) // 2
left_arr = arr[:mid]
right_arr = arr[mid:]
# Assert partition invariant: parts together == original
assert len(left_arr) + len(right_arr) == len(arr), (
f"Partition invariant: {len(left_arr)} + {len(right_arr)} != {len(arr)}"
)
# Conquer (recursive)
left_sorted = merge_sort(left_arr)
right_sorted = merge_sort(right_arr)
# Assert recursive post-conditions hold before merging
assert left_sorted == sorted(left_sorted), (
f"Left sub-array not sorted after recursive call: {left_sorted}"
)
assert right_sorted == sorted(right_sorted), (
f"Right sub-array not sorted after recursive call: {right_sorted}"
)
assert sorted(left_sorted + right_sorted) == sorted(arr), (
"Sub-arrays together don't contain the same elements as input"
)
# Merge
result = _merge(left_sorted, right_sorted)
# Post-conditions
assert result == sorted(result), (
f"Merge produced unsorted result: {result}"
)
assert sorted(result) == sorted(arr), (
f"Merge lost or gained elements: result={result}, original={arr}"
)
assert len(result) == len(arr), (
f"Merge changed length: got {len(result)}, expected {len(arr)}"
)
return result
def _merge(left: list, right: list) -> list:
"""Merge two sorted lists into one sorted list.
Pre-conditions:
- left is sorted
- right is sorted
Post-conditions:
- result is sorted
- result contains all elements from left and right
"""
# Pre-conditions
assert left == sorted(left), f"_merge pre-condition: left not sorted: {left}"
assert right == sorted(right), f"_merge pre-condition: right not sorted: {right}"
result = []
i = j = 0
while i < len(left) and j < len(right):
# LOOP INVARIANT: result is sorted and contains
# left[0:i] and right[0:j] in sorted order
assert result == sorted(result), (
f"Loop invariant broken: result not sorted at i={i}, j={j}"
)
if left[i] <= right[j]:
result.append(left[i])
i += 1
else:
result.append(right[j])
j += 1
# Append remaining elements
result.extend(left[i:])
result.extend(right[j:])
# Post-conditions
assert len(result) == len(left) + len(right), "Merge lost elements"
assert result == sorted(result), f"_merge post-condition: result not sorted: {result}"
return result
# Test with various inputs
test_cases = [
[],
[1],
[3, 1, 4, 1, 5, 9, 2, 6, 5, 3],
[5, 4, 3, 2, 1], # reverse sorted
[1, 2, 3, 4, 5], # already sorted
["banana", "apple", "cherry", "date"],
[3.14, 2.71, 1.41, 1.73],
]
for case in test_cases:
result = merge_sort(case)
print(f"Input: {case}")
print(f"Sorted: {result}")
print()
# Output:
# Input: []
# Sorted: []
#
# Input: [1]
# Sorted: [1]
#
# Input: [3, 1, 4, 1, 5, 9, 2, 6, 5, 3]
# Sorted: [1, 1, 2, 3, 3, 4, 5, 5, 6, 9]
#
# Input: [5, 4, 3, 2, 1]
# Sorted: [1, 2, 3, 4, 5]
# ... etc.
Quick Reference
| Pattern | Correct Tool | Reason |
|---|---|---|
| User input validation | if/raise | Must run in production; assert disabled under -O |
| API/config validation | if/raise | Same - external data is never trustworthy |
| Class invariant (production-critical) | if/raise in setter | Always enforced |
| Class invariant (development check) | assert in _check_invariant() | Disabled under -O for performance |
| Loop invariant | assert | Development debugging / correctness verification |
| Pre-condition on internal function | assert | Documents contract between internal callers |
| Post-condition check | assert | Verifies algorithm correctness during development |
| Unreachable branch | assert False | Marks code that should never execute |
| Type narrowing | assert isinstance(x, T) | Narrows type for static analysis tools |
| Non-None assertion | assert x is not None | Narrows Optional[T] to T for type checker |
| Disable assertion globally | python -O script.py | Optimised deployment - removes all assert |
Key Takeaways
assert condition, messageraisesAssertionErrorif condition is falsy - but the entire statement is removed underpython -O, making it invisible to production deployments- Never use
assertfor user input validation, security checks, or business rule enforcement - useif/raisewith appropriate exception types for anything that must hold in production - The correct role of
assertis executable documentation of invariants: conditions that should never be false if your code is correct, used during development and testing - Class invariants (
_check_invariant()) document properties that must hold for every valid object instance; loop invariants document what a loop maintains across iterations - Pre-conditions and post-conditions (
assertat function entry and exit) implement informal Design by Contract - making your algorithm's correctness requirements explicit and verifiable assert isinstance(x, T)provides type narrowing recognised by mypy and Pyright, in addition to the runtime check- In ML pipelines, assertions on tensor shapes, dtypes, and value ranges catch data bugs immediately at the pipeline boundary rather than letting them corrupt model training silently
