Skip to content

Python Assert Statement: Debug Smarter, Not Harder

Updated on

You are debugging a data processing function. Somewhere between reading the CSV and writing the output, a column of integers turned into floats, a list that should never be empty became empty, and a user ID that should be positive is now -1. You scatter print() statements everywhere, re-run the script, squint at the output, and repeat. An hour later you find the bug -- a single function was silently accepting bad input and passing corrupted data downstream. The real failure happened fifty lines before the crash, and nothing told you.

📚

This is a universal problem in Python development. Bugs propagate silently. A None value sneaks through three function calls before it causes an AttributeError. A negative array index wraps around to the wrong element. A dictionary that was supposed to have five keys only has four, and the missing key causes a subtle logic error that only surfaces in production. By the time the error becomes visible, you have lost all context about where things actually went wrong.

Python's assert statement solves this by catching bugs at the exact point of failure, with a clear message about what went wrong. Instead of hoping bad data will eventually cause an obvious crash, you declare your assumptions explicitly -- and Python enforces them immediately.

What Is the Assert Statement?

The assert statement tests a condition. If the condition is True, nothing happens. If the condition is False, Python raises an AssertionError immediately.

assert 2 + 2 == 4      # Passes silently
assert 2 + 2 == 5      # Raises AssertionError

The basic syntax is:

assert condition
assert condition, "Error message explaining what went wrong"

Internally, Python translates assert into an if statement. This is the exact equivalent:

# assert condition, message
# is equivalent to:
if __debug__:
    if not condition:
        raise AssertionError(message)

The __debug__ variable is True by default. It becomes False only when Python runs with the -O (optimize) flag. This means assertions can be completely disabled in production -- a feature that has important implications we will cover later.

Here is what happens when an assertion fails:

x = -1
assert x >= 0, f"Expected non-negative value, got {x}"

Output:

Traceback (most recent call last):
  File "example.py", line 2, in <module>
    assert x >= 0, f"Expected non-negative value, got {x}"
AssertionError: Expected non-negative value, got -1

The traceback points to the exact line where the assumption was violated, and the message tells you exactly what went wrong. Compare this to a mysterious IndexError twenty lines later because negative values propagated unchecked.

Basic Assert Usage

Simple Assertions

The simplest assertions check a single condition:

# Check that a variable is not None
config = load_config()
assert config is not None
 
# Check that a list is not empty
items = get_items()
assert len(items) > 0
 
# Check a mathematical property
result = calculate_discount(price=100, percent=20)
assert result == 80

Assertions with Custom Messages

Always include a message. Without one, a failed assertion gives you almost no context:

# Bad: no message
assert len(users) > 0
 
# Good: descriptive message
assert len(users) > 0, "User list is empty -- database query may have failed"
 
# Good: include the actual value
assert temperature >= -273.15, f"Temperature {temperature}C is below absolute zero"

The message is the second argument to assert, separated by a comma. It can be any expression that produces a string, including f-strings with runtime values:

def process_batch(items, batch_size):
    assert batch_size > 0, f"batch_size must be positive, got {batch_size}"
    assert len(items) >= batch_size, (
        f"Not enough items: need {batch_size}, have {len(items)}"
    )
    # Process the batch...

Assertions with Parentheses -- A Common Trap

There is a subtle bug that trips up many Python developers:

# WARNING: This assertion NEVER fails!
assert(condition, "error message")

This creates a tuple (condition, "error message"). A non-empty tuple is always truthy, so the assertion always passes. Python will even warn you:

SyntaxWarning: assertion is always true, perhaps remove parentheses?

The correct form is:

# Correct: no parentheses
assert condition, "error message"
 
# Also correct: parentheses only around the condition
assert (condition), "error message"
 
# Also correct: multi-line with implicit line continuation
assert (
    very_long_condition_that_needs_wrapping
), "error message"

Assert with Complex Conditions

Multiple Conditions

You can combine conditions with and, or, and not:

def create_user(name, age, email):
    assert name and isinstance(name, str), f"Invalid name: {name!r}"
    assert 0 < age < 150, f"Invalid age: {age}"
    assert "@" in email and "." in email, f"Invalid email format: {email}"
 
    # Proceed with user creation...

Type Checks with isinstance

Use isinstance assertions to verify data types during development:

def calculate_mean(values):
    assert isinstance(values, (list, tuple)), (
        f"Expected list or tuple, got {type(values).__name__}"
    )
    assert all(isinstance(v, (int, float)) for v in values), (
        "All values must be numeric"
    )
    assert len(values) > 0, "Cannot calculate mean of empty sequence"
 
    return sum(values) / len(values)

For production type checking, consider using Python type hints with a static type checker like mypy. Assertions are for catching bugs during development, not for runtime type enforcement.

Container and Collection Checks

# Check dictionary has required keys
required_keys = {"name", "email", "role"}
assert required_keys.issubset(user_data.keys()), (
    f"Missing keys: {required_keys - user_data.keys()}"
)
 
# Check list contains no duplicates
ids = [item.id for item in items]
assert len(ids) == len(set(ids)), (
    f"Duplicate IDs found: {[x for x in ids if ids.count(x) > 1]}"
)
 
# Check that all elements satisfy a condition
scores = [85, 92, 78, 95, 88]
assert all(0 <= s <= 100 for s in scores), (
    f"Scores out of range: {[s for s in scores if not 0 <= s <= 100]}"
)

Common Debugging Patterns with Assert

Function Preconditions

Preconditions verify that a function receives valid input before doing any work. Place them at the top of the function:

def transfer_money(from_account, to_account, amount):
    # Preconditions
    assert from_account != to_account, "Cannot transfer to the same account"
    assert amount > 0, f"Transfer amount must be positive, got {amount}"
    assert from_account.balance >= amount, (
        f"Insufficient funds: balance={from_account.balance}, transfer={amount}"
    )
 
    from_account.balance -= amount
    to_account.balance += amount

Function Postconditions

Postconditions verify that a function produced correct output before returning. Place them just before the return statement:

def sort_descending(items):
    result = sorted(items, reverse=True)
 
    # Postconditions
    assert len(result) == len(items), "Sort changed the number of elements"
    assert all(result[i] >= result[i+1] for i in range(len(result)-1)), (
        "Result is not sorted in descending order"
    )
 
    return result

Loop Invariants

Loop invariants verify that a condition holds true at every iteration of a loop. They catch off-by-one errors, infinite loops, and logic bugs:

def binary_search(sorted_list, target):
    low = 0
    high = len(sorted_list) - 1
 
    while low <= high:
        # Loop invariant: target must be in sorted_list[low:high+1] if it exists
        assert low >= 0 and high < len(sorted_list), (
            f"Bounds out of range: low={low}, high={high}, len={len(sorted_list)}"
        )
 
        mid = (low + high) // 2
        if sorted_list[mid] == target:
            return mid
        elif sorted_list[mid] < target:
            low = mid + 1
        else:
            high = mid - 1
 
    return -1

Class Invariants

Class invariants verify that an object's internal state remains consistent after every operation:

class BoundedQueue:
    """A queue with a maximum capacity."""
 
    def __init__(self, capacity):
        assert capacity > 0, f"Capacity must be positive, got {capacity}"
        self._items = []
        self._capacity = capacity
        self._check_invariant()
 
    def _check_invariant(self):
        assert 0 <= len(self._items) <= self._capacity, (
            f"Queue size {len(self._items)} violates capacity {self._capacity}"
        )
 
    def enqueue(self, item):
        assert len(self._items) < self._capacity, "Queue is full"
        self._items.append(item)
        self._check_invariant()
 
    def dequeue(self):
        assert len(self._items) > 0, "Queue is empty"
        item = self._items.pop(0)
        self._check_invariant()
        return item
 
    def __len__(self):
        return len(self._items)

Assert vs Raise: When to Use Each

This is one of the most important distinctions in Python error handling. assert and raise serve fundamentally different purposes.

Featureassertraise
PurposeCatch programmer errors (bugs)Handle runtime conditions (expected failures)
Can be disabledYes, with -O flagNo, always active
Use for input validationNeverYes
Use for external dataNeverYes
Typical exceptionAssertionErrorValueError, TypeError, RuntimeError, etc.
When it firesSomething is wrong with the codeSomething is wrong with the input/environment
AudienceThe developerThe user or calling code
Presence in productionShould not be relied uponRequired

Use assert for: Internal invariants and developer assumptions

def _calculate_tax(income, brackets):
    # Developer assumption: brackets are sorted
    assert all(
        brackets[i][0] <= brackets[i+1][0]
        for i in range(len(brackets) - 1)
    ), "Tax brackets must be sorted by threshold"
 
    # This is a bug in the code if brackets aren't sorted,
    # not a user input problem
    ...

Use raise for: Input validation and expected error conditions

def create_account(username, password):
    if not username or len(username) < 3:
        raise ValueError("Username must be at least 3 characters")
    if len(password) < 8:
        raise ValueError("Password must be at least 8 characters")
 
    # These are user input problems, not programmer bugs.
    # They must ALWAYS be checked, even in production.
    ...

The critical difference: if someone runs python -O your_script.py, every assert statement is completely removed. If you use assert for input validation, your validation disappears in optimized mode. This is not a theoretical risk -- many deployment tools and production environments use the -O flag. For a deeper dive into exception handling patterns, see the Python try/except guide.

The Rule of Thumb

Ask yourself: "If this check were removed entirely, could a user cause a security issue or data corruption?" If yes, use raise. If the check only catches developer mistakes (bugs in the code itself), use assert.

Assert in Testing

Assertions are the backbone of testing in Python. Both unittest and pytest rely on assertions to verify expected behavior.

pytest Assertions

pytest uses plain assert statements instead of special assertion methods. This is one of its biggest advantages -- you write natural Python instead of memorizing method names:

# test_math.py
def test_addition():
    assert 2 + 2 == 4
 
def test_string_methods():
    greeting = "hello world"
    assert greeting.upper() == "HELLO WORLD"
    assert greeting.split() == ["hello", "world"]
 
def test_list_operations():
    items = [1, 2, 3]
    items.append(4)
    assert len(items) == 4
    assert items[-1] == 4

pytest Assert Rewriting

What makes pytest special is assert rewriting. When a plain assert fails, Python only says AssertionError. pytest rewrites your assert statements at import time to provide rich failure messages:

def test_comparison():
    result = {"name": "Alice", "age": 30}
    expected = {"name": "Alice", "age": 31}
    assert result == expected

pytest output:

FAILED test_example.py::test_comparison - AssertionError: assert {'age': 30, 'name': 'Alice'} == {'age': 31, 'name': 'Alice'}
  Differing items:
  {'age': 30} != {'age': 31}

Without pytest's rewriting, you would just see AssertionError with no details. This magic works because pytest uses an import hook to transform assert statements into more verbose checks that capture intermediate values.

Common pytest Assertion Patterns

# Check that an exception is raised
import pytest
 
def test_division_by_zero():
    with pytest.raises(ZeroDivisionError):
        1 / 0
 
def test_invalid_input():
    with pytest.raises(ValueError, match="must be positive"):
        create_user(age=-5)
 
# Check approximate equality (for floats)
def test_float_calculation():
    result = 0.1 + 0.2
    assert result == pytest.approx(0.3)
 
# Check that a value is in a collection
def test_membership():
    valid_statuses = {"active", "inactive", "pending"}
    user_status = get_user_status(user_id=42)
    assert user_status in valid_statuses
 
# Check with custom message
def test_data_integrity():
    records = load_records()
    assert len(records) > 0, "No records loaded -- check database connection"

Assertions in unittest

The unittest module provides method-based assertions instead of plain assert. These give better error messages without needing pytest's rewriting:

import unittest
 
class TestStringMethods(unittest.TestCase):
    def test_upper(self):
        self.assertEqual("hello".upper(), "HELLO")
 
    def test_contains(self):
        self.assertIn("world", "hello world")
 
    def test_raises(self):
        with self.assertRaises(TypeError):
            "hello" + 5

Both approaches are valid. pytest's plain assert is more readable and Pythonic. unittest's method-based assertions provide detailed messages without import-time rewriting.

Testing Assertions Interactively

When developing and debugging test assertions interactively, tools like RunCell (opens in a new tab) let you run individual test cells in Jupyter notebooks with instant feedback. This is especially useful when you are building up complex assertion conditions step by step -- you can test each assertion in isolation before combining them into a full test suite.

When NOT to Use Assert

This section is critical. Misusing assert creates subtle, dangerous bugs that only appear in production.

Never Use Assert for Input Validation

# WRONG: This check disappears with python -O
def withdraw(amount):
    assert amount > 0, "Amount must be positive"
    self.balance -= amount
 
# RIGHT: This check always runs
def withdraw(amount):
    if amount <= 0:
        raise ValueError("Amount must be positive")
    self.balance -= amount

Never Use Assert for Data from External Sources

Any data from users, files, networks, databases, or APIs can be malformed. These checks must always run:

# WRONG: Network data validation with assert
def handle_api_response(response):
    assert response.status_code == 200
    data = response.json()
    assert "results" in data
 
# RIGHT: Proper error handling for external data
def handle_api_response(response):
    if response.status_code != 200:
        raise RuntimeError(f"API returned status {response.status_code}")
    data = response.json()
    if "results" not in data:
        raise ValueError("API response missing 'results' field")

Never Use Assert for Security Checks

# CATASTROPHICALLY WRONG: Security check with assert
def delete_user(requesting_user, target_user_id):
    assert requesting_user.is_admin, "Only admins can delete users"
    database.delete(target_user_id)
 
# RIGHT: Security check that cannot be disabled
def delete_user(requesting_user, target_user_id):
    if not requesting_user.is_admin:
        raise PermissionError("Only admins can delete users")
    database.delete(target_user_id)

With python -O, the assert version lets any user delete any other user. This is a real security vulnerability.

Never Use Assert with Side Effects

Because assertions can be disabled, the expression inside them should never have side effects:

# WRONG: The pop() is a side effect that disappears with -O
assert items.pop() == expected_value
 
# RIGHT: Separate the side effect from the assertion
value = items.pop()
assert value == expected_value

The -O Flag: How Assertions Disappear

Python has two optimization levels that affect assertions:

python script.py        # Normal: __debug__ is True, assertions active
python -O script.py     # Optimize: __debug__ is False, assertions removed
python -OO script.py    # Extra optimize: assertions removed + docstrings removed

When Python runs with -O, the interpreter sets __debug__ to False and completely removes all assert statements from the bytecode. They do not just get skipped -- they cease to exist. The condition is never evaluated, and the error message is never constructed.

You can verify this:

# check_debug.py
print(f"__debug__ = {__debug__}")
 
if __debug__:
    print("Assertions are ACTIVE")
else:
    print("Assertions are DISABLED")
 
assert False, "This should raise an error"
$ python check_debug.py
__debug__ = True
Assertions are ACTIVE
Traceback (most recent call last):
  File "check_debug.py", line 8
AssertionError: This should raise an error
 
$ python -O check_debug.py
__debug__ = False
Assertions are DISABLED
# No error! The assert was completely removed.

Where -O Is Used in Practice

  • Docker images: Many production Dockerfiles use PYTHONOPTIMIZE=1 or python -O
  • Deployment tools: Some WSGI servers run Python in optimized mode
  • Performance-sensitive applications: Removing assertions can speed up tight loops
  • Library code: Libraries should never assume assertions are active because consumers control the optimization level

Implications for Your Code

Think of assertions as scaffolding during construction. They support the structure while you build it, but they get removed when the building is done. Your code must be correct whether or not assertions are present.

# This code works correctly with or without assertions:
def safe_divide(a, b):
    assert isinstance(a, (int, float)), f"Expected number, got {type(a)}"
    assert isinstance(b, (int, float)), f"Expected number, got {type(b)}"
 
    if b == 0:
        raise ValueError("Division by zero")
    return a / b

The assertions help catch bugs during development. The raise handles the expected error condition in production. Both layers serve different purposes.

Custom Assertion Helpers

When you find yourself writing the same assertion pattern repeatedly, extract it into a reusable helper function.

Simple Assertion Functions

def assert_positive(value, name="value"):
    """Assert that a value is a positive number."""
    assert isinstance(value, (int, float)), (
        f"{name} must be a number, got {type(value).__name__}"
    )
    assert value > 0, f"{name} must be positive, got {value}"
 
 
def assert_valid_probability(p, name="probability"):
    """Assert that a value is a valid probability (0 to 1)."""
    assert isinstance(p, (int, float)), (
        f"{name} must be a number, got {type(p).__name__}"
    )
    assert 0 <= p <= 1, f"{name} must be between 0 and 1, got {p}"
 
 
def assert_same_length(*sequences, names=None):
    """Assert that all sequences have the same length."""
    lengths = [len(s) for s in sequences]
    if names:
        details = ", ".join(f"{n}={l}" for n, l in zip(names, lengths))
    else:
        details = ", ".join(str(l) for l in lengths)
    assert len(set(lengths)) == 1, (
        f"Length mismatch: {details}"
    )
 
 
# Usage
def calculate_weighted_average(values, weights):
    assert_same_length(values, weights, names=["values", "weights"])
    assert_valid_probability(sum(weights) / len(weights), "average weight")
 
    return sum(v * w for v, w in zip(values, weights)) / sum(weights)

Decorator-Based Assertions

You can use decorators to add pre/post-condition checks to functions without cluttering the function body:

import functools
 
def preconditions(**checks):
    """Decorator that asserts preconditions on function arguments."""
    def decorator(func):
        @functools.wraps(func)
        def wrapper(*args, **kwargs):
            import inspect
            sig = inspect.signature(func)
            bound = sig.bind(*args, **kwargs)
            bound.apply_defaults()
 
            for param_name, check_func in checks.items():
                value = bound.arguments[param_name]
                assert check_func(value), (
                    f"Precondition failed for '{param_name}': "
                    f"got {value!r}"
                )
            return func(*args, **kwargs)
        return wrapper
    return decorator
 
 
@preconditions(
    x=lambda v: isinstance(v, (int, float)) and v >= 0,
    n=lambda v: isinstance(v, int) and v > 0
)
def nth_root(x, n):
    """Calculate the nth root of x."""
    return x ** (1 / n)
 
 
# This passes
print(nth_root(27, 3))  # 3.0
 
# This fails with a clear message
print(nth_root(-1, 2))  # AssertionError: Precondition failed for 'x': got -1

Context Manager for Assertion Groups

When you need to check multiple related conditions and report all failures at once:

class AssertionGroup:
    """Collect multiple assertion failures and report them together."""
 
    def __init__(self, description=""):
        self.description = description
        self.failures = []
 
    def check(self, condition, message):
        if not condition:
            self.failures.append(message)
 
    def verify(self):
        if self.failures:
            header = f"{self.description}: " if self.description else ""
            details = "\n  - ".join(self.failures)
            assert False, f"{header}{len(self.failures)} checks failed:\n  - {details}"
 
 
# Usage
def validate_user_record(record):
    checks = AssertionGroup("User record validation")
    checks.check("name" in record, "Missing 'name' field")
    checks.check("email" in record, "Missing 'email' field")
    checks.check(
        record.get("age", 0) > 0,
        f"Invalid age: {record.get('age')}"
    )
    checks.check(
        "@" in record.get("email", ""),
        f"Invalid email: {record.get('email')}"
    )
    checks.verify()  # Raises with all failures at once

Handling AssertionError with Try/Except

You can catch AssertionError like any other exception, though it is rarely a good idea in application code:

try:
    assert len(data) > 0, "Data is empty"
    process(data)
except AssertionError as e:
    print(f"Assertion failed: {e}")
    # Handle the failure...

When Catching AssertionError Makes Sense

There are a few legitimate use cases:

1. Test frameworks: pytest and unittest catch AssertionError to report test failures instead of crashing.

2. Logging assertion failures in long-running processes:

import logging
 
logger = logging.getLogger(__name__)
 
def process_records(records):
    failed = []
    for record in records:
        try:
            assert_valid_record(record)
            process(record)
        except AssertionError as e:
            logger.error(f"Skipping invalid record: {e}")
            failed.append(record)
 
    if failed:
        logger.warning(f"{len(failed)} records failed validation")
    return failed

For production logging patterns, catching AssertionError should be combined with proper exception handling to ensure failures are visible without crashing the entire process.

3. Graceful degradation in non-critical paths:

def generate_report(data):
    report = {"data": data, "charts": []}
 
    try:
        assert len(data) >= 10, "Not enough data for chart"
        chart = create_chart(data)
        report["charts"].append(chart)
    except AssertionError:
        report["charts_note"] = "Insufficient data for visualization"
 
    return report

When NOT to Catch AssertionError

Do not catch AssertionError to silently suppress bugs. The whole point of assertions is to make bugs loud and visible:

# WRONG: Silencing assertions defeats their purpose
try:
    assert user.is_valid()
except AssertionError:
    pass  # Who cares?

Real-World Examples

Data Pipeline Validation

Assertions are invaluable in data processing pipelines where data transformations must preserve certain properties:

import pandas as pd
 
def clean_sales_data(df):
    """Clean and validate sales data."""
    assert isinstance(df, pd.DataFrame), f"Expected DataFrame, got {type(df)}"
    assert len(df) > 0, "DataFrame is empty"
 
    initial_rows = len(df)
 
    # Remove duplicates
    df = df.drop_duplicates(subset=["order_id"])
    assert len(df) > 0, "All rows were duplicates"
 
    # Validate required columns
    required = {"order_id", "product", "quantity", "price"}
    assert required.issubset(df.columns), (
        f"Missing columns: {required - set(df.columns)}"
    )
 
    # Clean numeric columns
    df["quantity"] = pd.to_numeric(df["quantity"], errors="coerce")
    df["price"] = pd.to_numeric(df["price"], errors="coerce")
 
    # Drop rows with invalid numbers
    df = df.dropna(subset=["quantity", "price"])
 
    # Postcondition: all prices and quantities are positive
    assert (df["price"] > 0).all(), (
        f"Found {(df['price'] <= 0).sum()} non-positive prices"
    )
    assert (df["quantity"] > 0).all(), (
        f"Found {(df['quantity'] <= 0).sum()} non-positive quantities"
    )
 
    # Calculate total
    df["total"] = df["quantity"] * df["price"]
    assert (df["total"] > 0).all(), "Totals must be positive"
 
    print(f"Cleaned {initial_rows} -> {len(df)} rows")
    return df

When working with DataFrames in data science workflows, PyGWalker (opens in a new tab) lets you turn validated DataFrames into interactive visualizations for further exploration -- a natural next step after your pipeline assertions confirm the data is clean and ready for analysis.

API Response Checking

import requests
 
def fetch_user_profile(user_id):
    """Fetch user profile from API with defensive assertions."""
    response = requests.get(f"https://api.example.com/users/{user_id}")
 
    # Use raise for external data validation (not assert!)
    if response.status_code != 200:
        raise RuntimeError(f"API error: {response.status_code}")
 
    data = response.json()
    if "user" not in data:
        raise ValueError("API response missing 'user' field")
 
    user = data["user"]
 
    # Use assert for internal invariants -- things that should
    # always be true if the API contract is correct
    assert "id" in user, "API contract violation: user missing 'id'"
    assert user["id"] == user_id, (
        f"API returned wrong user: requested {user_id}, got {user['id']}"
    )
 
    return user

Notice the distinction: raise handles expected error conditions (network issues, bad status codes). assert catches things that indicate a bug in the API or in your code's assumptions about the API.

Machine Learning Model Sanity Checks

import numpy as np
 
def train_model(X_train, y_train, X_test, y_test):
    """Train a model with sanity checks at each stage."""
 
    # Data shape assertions
    assert X_train.ndim == 2, f"X_train must be 2D, got {X_train.ndim}D"
    assert y_train.ndim == 1, f"y_train must be 1D, got {y_train.ndim}D"
    assert X_train.shape[0] == y_train.shape[0], (
        f"Sample count mismatch: X={X_train.shape[0]}, y={y_train.shape[0]}"
    )
    assert X_train.shape[1] == X_test.shape[1], (
        f"Feature count mismatch: train={X_train.shape[1]}, test={X_test.shape[1]}"
    )
 
    # No NaN or Inf in data
    assert not np.isnan(X_train).any(), "X_train contains NaN values"
    assert not np.isinf(X_train).any(), "X_train contains Inf values"
 
    # Labels are valid
    unique_labels = np.unique(y_train)
    assert len(unique_labels) >= 2, (
        f"Need at least 2 classes, got {len(unique_labels)}"
    )
 
    # Train the model
    model = fit(X_train, y_train)
 
    # Predictions sanity check
    predictions = model.predict(X_test)
    assert predictions.shape == y_test.shape, (
        f"Prediction shape {predictions.shape} != target shape {y_test.shape}"
    )
    assert set(predictions).issubset(set(unique_labels)), (
        f"Model predicted unknown labels: {set(predictions) - set(unique_labels)}"
    )
 
    # Accuracy sanity check (should be better than random)
    accuracy = np.mean(predictions == y_test)
    random_baseline = 1 / len(unique_labels)
    assert accuracy > random_baseline * 0.8, (
        f"Accuracy {accuracy:.2%} is worse than random ({random_baseline:.2%})"
    )
 
    return model

State Machine Transitions

class OrderStateMachine:
    VALID_TRANSITIONS = {
        "created": {"confirmed", "cancelled"},
        "confirmed": {"shipped", "cancelled"},
        "shipped": {"delivered", "returned"},
        "delivered": {"returned"},
        "cancelled": set(),
        "returned": set(),
    }
 
    def __init__(self):
        self.state = "created"
        self.history = ["created"]
 
    def transition(self, new_state):
        assert new_state in self.VALID_TRANSITIONS.get(self.state, set()), (
            f"Invalid transition: {self.state} -> {new_state}. "
            f"Valid transitions: {self.VALID_TRANSITIONS[self.state]}"
        )
 
        self.state = new_state
        self.history.append(new_state)
 
        # Invariant: history should always start with "created"
        assert self.history[0] == "created", "History corrupted"
        # Invariant: current state should match last history entry
        assert self.state == self.history[-1], "State/history mismatch"

Performance Considerations

How Much Do Assertions Cost?

Assertions have a small but measurable cost. The condition expression is evaluated every time the assertion runs. For simple checks like assert x > 0, this is negligible. For expensive checks, the cost can add up:

import time
 
data = list(range(1_000_000))
 
# Fast assertion: O(1)
start = time.perf_counter()
for _ in range(10_000):
    assert len(data) > 0
fast_time = time.perf_counter() - start
print(f"Simple assertion: {fast_time:.4f}s")
 
# Slow assertion: O(n) -- checks every element
start = time.perf_counter()
for _ in range(100):
    assert all(isinstance(x, int) for x in data)
slow_time = time.perf_counter() - start
print(f"Expensive assertion: {slow_time:.4f}s")

Strategies for Expensive Assertions

If an assertion is too slow for a tight loop, you have several options:

1. Check a sample instead of the whole dataset:

import random
 
def process_large_dataset(records):
    # Check a random sample instead of all records
    sample = random.sample(records, min(100, len(records)))
    assert all(is_valid(r) for r in sample), "Invalid records found in sample"
    # Process all records...

2. Use the __debug__ flag for conditional expensive checks:

def matrix_multiply(a, b):
    if __debug__:
        # This entire block is removed with python -O
        assert a.shape[1] == b.shape[0], (
            f"Incompatible shapes: {a.shape} x {b.shape}"
        )
        # Expensive but helpful during development
        assert not np.isnan(a).any(), "Matrix a contains NaN"
        assert not np.isnan(b).any(), "Matrix b contains NaN"
 
    return a @ b

3. Only assert on boundaries, not inside inner loops:

def process_batch(items):
    # Assert once at the boundary
    assert all(item.is_valid() for item in items), "Invalid items in batch"
 
    # Inner loop without assertions for performance
    results = []
    for item in items:
        # No assertions here -- we validated above
        result = transform(item)
        results.append(result)
 
    # Assert once at the output boundary
    assert len(results) == len(items), "Result count mismatch"
    return results

Best Practices Summary

Here are the key principles for effective assertion usage:

1. Always include a message. assert x > 0 tells you nothing when it fails. assert x > 0, f"Expected positive value, got {x}" tells you everything.

2. Never use assert for input validation. User input, file contents, API responses, and database queries can all be malformed. Validate them with if/raise.

3. Use assert for internal invariants. Things that should always be true if your code is correct: function preconditions, postconditions, loop invariants, class invariants.

4. Never put side effects inside assert. The expression assert items.pop() == expected removes an item from the list -- but only when assertions are enabled.

5. Use assert liberally during development. They cost nothing when disabled with -O and save hours of debugging when enabled.

6. Keep assertion messages actionable. Include the actual value, the expected value, and enough context to understand what went wrong.

7. Test your assertions. Write tests that verify your assertions catch the bugs they are supposed to catch.

import pytest
 
def test_transfer_rejects_negative_amount():
    with pytest.raises(AssertionError, match="positive"):
        transfer_money(account_a, account_b, amount=-100)

FAQ

Conclusion

The Python assert statement is a lightweight, powerful tool for defensive programming. It turns implicit assumptions into explicit, enforced checks that catch bugs at the point of failure instead of letting bad data propagate through your code. Used correctly, assertions make debugging faster, code more readable, and invariants self-documenting.

The key rules are straightforward: use assert for internal invariants and developer assumptions, use raise for input validation and expected error conditions, always include descriptive messages, and never put side effects inside assert expressions. In testing, both pytest and unittest rely heavily on assertions to verify expected behavior.

For data science and analysis workflows, assertions pair naturally with tools like PyGWalker (opens in a new tab) for validating DataFrames before visualization, and interactive environments like RunCell (opens in a new tab) for iteratively building and testing assertion-guarded data pipelines in Jupyter notebooks.

Master these patterns and your debugging sessions will get shorter, your code will become more robust, and your tests will be more expressive.

Related Guides

📚