C's assert Side-Effect Trap: The Code That Vanishes in Release Builds

2026-06-09

This program drains a queue of five events, doubles each value, and totals the result. Compile it with gcc -O2 main.c and run it. Now compile with gcc -O2 -DNDEBUG main.c and run it again. Same source, same compiler, wildly different output — and in the release build, often garbage or a crash.

#include <assert.h>
#include <stdio.h>

int dequeue(int *queue, int *count) {
    if (*count == 0) return -1;
    int val = queue[0];
    for (int i = 1; i < *count; i++) queue[i-1] = queue[i];
    (*count)--;
    return val;
}

int process_event(int *queue, int *count) {
    int event;
    /* Pull next event; must be non-negative. */
    assert((event = dequeue(queue, count)) >= 0);
    return event * 2;
}

int main(void) {
    int queue[] = {10, 20, 30, 40, 50};
    int count = 5, total = 0;
    for (int i = 0; i < 5; i++) total += process_event(queue, &count);
    printf("Total: %d (count left: %d)\n", total, count);
    return 0;
}

The Bug

The assert macro is defined to expand to nothing when NDEBUG is defined — which is the convention for release builds, and the default for CMake's Release and RelWithDebInfo configurations. When the macro vanishes, so does everything inside its parentheses, including the call to dequeue.

In a debug build, process_event dequeues one element, assigns it to event, asserts it's non-negative, and doubles it. Total: 300, count left: 0.

In a release build with -DNDEBUG, the assert(...) line compiles to nothing. dequeue is never called. The queue is never drained. Worse, event is read while uninitialized — undefined behavior. The optimizer is free to assume that path unreachable, propagate poison, or just hand you whatever was on the stack. Total: garbage. Count left: 5.

The C11 standard is explicit: when NDEBUG is defined, assert expands to ((void)0). The expression isn't evaluated, isn't side-effect-preserved, isn't anything. It's gone.

The rule: never put work inside an assert. Asserts are documentation that the compiler happens to check in debug mode. The body must be a side-effect-free predicate. Function calls that allocate, mutate state, return status codes, or read input all qualify as side effects. Even harmless-looking helpers can grow side effects later and silently break release builds.

The fix is to extract the call, then assert on the result:

int process_event(int *queue, int *count) {
    int event = dequeue(queue, count);
    assert(event >= 0);  /* checks, but doesn't *do* the work */
    return event * 2;
}

Now dequeue runs in every build, and the assertion is purely a debug-mode sanity check. If you want a check that survives in release, use a real conditional with abort(), an error return, or a logging path — not assert.

This bug is especially insidious because it passes every test you run locally (debug builds), passes CI (often debug builds), and only manifests in the shipped binary. Code-review heuristic: grep for assert( followed by anything that looks like a function call, assignment, or ++. Every hit is a potential time bomb.

Key Takeaway: assert expressions are completely deleted when NDEBUG is defined — any function calls, assignments, or side effects inside them disappear with the macro, so asserts must only contain side-effect-free predicates.

All newsletters