The Assertion Anxiety

Most production codebases fall into one of two camps. Camp A treats assert as a decorative seasoning, sprinkling it on every other line until the function reads like a legal contract written by a paranoid lawyer. Camp B treats assertions as development-only training wheels, stripping them all at build time and hoping the code works in production because the tests passed once.

Both camps are wrong. The question isn’t whether to assert. The question is what an assertion actually means.

An assertion is not error handling. It is not input validation. It is not a polite suggestion. An assertion is a claim that something is impossible. If the assertion fires, your mental model of the program is broken. That distinction determines everything about where assertions belong and how many you should write.

Assertions Are for Invariants, Not Errors

When a user passes a negative age to your API, that is an error. Errors are expected. Errors deserve real handling, logging, and user-facing messages. When your internal calculation produces a negative count of database rows after a supposedly successful query, that is an invariant violation. That should never happen. That is what assertions exist for.

This sounds obvious until you read production code. I have seen functions that assert a string is non-empty, then three lines later check if (!str) and throw a formatted exception. The developer used both tools for the same condition because they never decided which one was the real contract.

Here is the rule. If the condition can be triggered by external input, it is not an assertion. If it can only be triggered by a bug in your own code, it is.

def process_payment(user_id: str, amount_cents: int) -> Receipt:
    # NOT an assertion. Users or upstream services can send bad data.
    if amount_cents <= 0:
        raise ValueError("amount_cents must be positive")

    # NOT an assertion. The user_id comes from the outside world.
    if not user_id:
        raise ValueError("user_id is required")

    receipt = _charge_card(user_id, amount_cents)

    # THIS is an assertion. If charge_card returned None after
    # succeeding, our understanding of the universe is wrong.
    assert receipt is not None, "charge_card succeeded but returned None"

    # THIS is an assertion. A receipt with zero items after a
    # successful charge means our internal logic is broken.
    assert len(receipt.items) > 0, "receipt has no items after successful charge"

    return receipt

The first two checks guard the boundary. The last two guard the internal consistency of the system. Mixing them creates confusion about who is responsible for what.

The Three-Assertion Ceiling

If you find yourself writing more than three assertions in a single function, you have one of two problems. Either your function does too many things, or your invariants are too vague to enforce.

A function with twelve assertions is not defensive. It is uncertain. The author does not trust the code that calls it, the code it calls, or the data flowing between them. That uncertainty should be resolved by refactoring, not by adding more assert statements.

The practical limit comes from what a developer can hold in their head. A function should have one clear contract. That contract implies a small number of invariants. If you need a dozen assertions to feel safe, your function has probably absorbed responsibilities that belong elsewhere.

Split the function. Extract the part that transforms data. Extract the part that calls external services. Give each extracted function its own small set of invariants. Three assertions per function is a warning light. Five is a flat tire.

Assertions in Production: On or Off?

Different languages make different choices. Python strips assert statements when you run with the -O flag. C and C++ compilers routinely remove assertions in release builds. JavaScript has no built-in assert at all. You either polyfill it or use a library that stays active in production.

This creates a genuine dilemma. If you strip assertions, you lose the safety net exactly when you need it most. Bugs that only appear in production will silently corrupt data instead of failing fast. If you keep them, you risk crashing a production process over a condition that, while theoretically impossible, is not actually fatal.

The answer depends on the cost of continuation. If violating the invariant means the next operation will corrupt the database or leak sensitive data, the assertion should crash the process. A hard stop is better than a silent breach. If violating the invariant means a slightly wrong log entry or a minor UI glitch, log it and continue.

// This should probably crash. Continuing with a null user
// after auth succeeded is a security hole waiting to happen.
assert(user !== null, "auth middleware returned null user after success");

// This should probably not crash. A stale cache timestamp
// is annoying but not dangerous.
if (cache.timestamp > Date.now()) {
  logger.warn("cache timestamp is in the future, ignoring");
}

Not every invariant deserves the same posture. Learn to tell the difference between “this must stop” and “this is weird but survivable.”

What We Tried That Did Not Work

Early in one project, we tried asserting every function precondition. Every argument was checked for null, type, range, and format. The result was predictable. Tests passed beautifully. Production crashed the first time a third-party API returned a field as a string instead of a number.

The problem was not the assertion. The problem was that we asserted on data from outside our control, then compiled with assertions enabled in production. A malformed external response killed our process instead of being sanitized and handled. We had built a system that was internally consistent and externally fragile.

We learned to separate the boundary from the interior. At the boundary, parse and validate aggressively. Convert external chaos into internal certainty. Inside the boundary, assert the invariants that define that certainty. The assertions stayed. The input validation moved to explicit parsing functions that returned Result types instead of throwing.

A Practical Checklist

Before you add an assertion, run through this list:

  1. Can external input trigger this? If yes, use validation, not assertion.
  2. If this fires in production, should the process stop? If no, log a warning instead.
  3. Does this function already have three or more assertions? If yes, consider refactoring before adding another.
  4. Will this assertion still make sense to someone reading the code in six months? Obscure assertions get deleted in refactors. Clear ones survive.

Assertions are a communication tool as much as a safety tool. They tell the next developer, “this condition is impossible by design.” If the condition is not actually impossible by design, the assertion is lying. And lies in production code are expensive.

FAQ

Should I assert on function arguments?

Only if the caller is also your code and the argument is a product of internal logic, not external input. Public API functions should validate. Private helper functions can assert invariants about the values they receive.

What about TypeScript? It already catches nulls at compile time.

TypeScript’s type system is a powerful assertion layer, but it vanishes at runtime. Use it for everything the compiler can prove. Add runtime assertions for the gaps: API responses, deserialized data, and any as cast that bypasses the type checker.

Do assertions hurt performance?

In most languages, a well-placed assertion costs microseconds. If you are asserting inside a tight loop processing millions of items, move the assertion outside the loop. Check the invariant on the batch, not on every element.

Should I write custom assert functions?

Only when the built-in assertion message would be unhelpful. A custom assertNonEmpty that prints the actual array length is more useful than a generic assert len(items) > 0 that crashes with no context. Keep them small. Do not build an assertion framework.