Skip to main content

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 assert statement does and how it compiles to bytecode
  • Why assertions are disabled under python -O and what that means for production code
  • The critical rule: never use assert for 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:

  1. Under python -O, all three asserts disappear. A 10-year-old user with a 1-character username can create an account.
  2. AssertionError is not a meaningful error type for callers to catch - they should get a ValueError or a domain-specific exception.
  3. Callers who write except ValueError to handle bad input will miss the AssertionError.

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:

  1. Could this condition be false due to a caller or user providing unexpected input? → Yes: use if/raise - No: it's an internal invariant
  2. Should this check survive in a production deployment? → Yes: use if/raise - No: assert is acceptable (development/testing only)
Use assertUse if/raise
Internal invariantUser input validation
Unreachable code pathsAPI request validation
Post-condition checksConfiguration validation
Development debuggingBusiness rule enforcement
Test helper assertionsSecurity 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:

  1. Documentation: they state precisely what the algorithm maintains
  2. 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

PatternCorrect ToolReason
User input validationif/raiseMust run in production; assert disabled under -O
API/config validationif/raiseSame - external data is never trustworthy
Class invariant (production-critical)if/raise in setterAlways enforced
Class invariant (development check)assert in _check_invariant()Disabled under -O for performance
Loop invariantassertDevelopment debugging / correctness verification
Pre-condition on internal functionassertDocuments contract between internal callers
Post-condition checkassertVerifies algorithm correctness during development
Unreachable branchassert FalseMarks code that should never execute
Type narrowingassert isinstance(x, T)Narrows type for static analysis tools
Non-None assertionassert x is not NoneNarrows Optional[T] to T for type checker
Disable assertion globallypython -O script.pyOptimised deployment - removes all assert

Key Takeaways

  • assert condition, message raises AssertionError if condition is falsy - but the entire statement is removed under python -O, making it invisible to production deployments
  • Never use assert for user input validation, security checks, or business rule enforcement - use if/raise with appropriate exception types for anything that must hold in production
  • The correct role of assert is 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 (assert at 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
© 2026 EngineersOfAI. All rights reserved.