C Part I · Module 13 · Writing Correct and Reliable C
assert and Checking Assumptions
Every mechanism in this module so far is something else doing the checking — a compiler, a flag, a tool watching the program run — and they share a limitation. They know what the language says, not what your program means. None of them can know that this index is always below that count, that this pointer is never null once control reaches here, or that this function is only called while a particular invariant holds, because none of those facts is expressible in C's type system. They are, however, expressible as a statement that stops the program the moment they stop being true. This chapter is about writing those, where they belong, and the one category of condition they must never be used for — a rule whose real reason is measurable and considerably more alarming than the usual explanation.
Intermediate18 min readCPart IFundamentalsCorrectness
Part I · Module 13 · Chapter 13.8 · assert and Checking Assumptions
1. What assert Does
#include <assert.h>
assert(expression);If the expression is false, assert writes a diagnostic and calls abort.
static int divide(int a, int b) { assert(b != 0); return a / b; }Assertion failed: (b != 0), function divide, file a2.c, line 3.exit status 134 (128 + 6, SIGABRT)Four pieces of information for one line of code: the expression that failed, the function, the file, and the line. Nothing else in C gives you that for free, and the expression text is possible only because assert is a macro — Chapter 11.6 §6's stringizing operator, used by the standard library itself.
And the failure is immediate. Chapter 12.7 §4 measured abort ending the program without running atexit handlers; here that is the point rather than a hazard. An assertion that fires stops the program at the first moment the assumption is known to be false, which is as close to the cause as you will ever get.
2. What It Is For
An assertion states something that is true unless the program has a bug.
That is the whole test. It is a statement about your own code's internal consistency, not about the world.
static int sum_range(const int *v, size_t lo, size_t hi, size_t n)
{
assert(v != NULL); /* callers inside this module always pass one */
assert(lo <= hi); /* the range is ordered by construction */
assert(hi <= n); /* established at the boundary — 13.6 §6 */
int total = 0;
for (size_t i = lo; i < hi; i++) { total += v[i]; }
return total;
}Each of those is a fact the function depends on, that no type can express, and that can only become false through a mistake elsewhere in the program. That is exactly what an assertion is for.
Four things assertions are good at:
Documenting a precondition in a form that is checked. A comment saying "lo must not exceed hi" is checked by nobody; the assertion is checked on every call in every debug build.
Making Chapter 13.6's "trust after" design safe. That chapter argued for validating at a boundary and trusting inside it. An assertion inside is how you verify the trust is justified, without turning the inner function back into a validating one.
Catching a violated invariant at its first observation rather than at the eventual symptom — Chapter 9.11 §4's distance between cause and effect, shortened deliberately.
Stating what cannot happen, which makes a reader's job easier:
switch (kind) {
case MSG_STATUS: ...; break;
case MSG_ERROR: ...; break;
case MSG_SAMPLE: ...; break;
default: assert(0 && "unreachable: kind came from a validated source");
}The && "message" idiom is worth knowing: the string is non-zero so it does not change the condition, and it appears in the diagnostic because the whole expression is stringized.
3. NDEBUG Removes It — Including the Side Effects
Here is the measurement the rest of the chapter depends on.
static int bump_count = 0;
static int bump(void) { bump_count++; return 1; }
int main(void)
{
assert(bump() == 1);
printf("bump_count after the assert = %d\n", bump_count);
...
}--- without NDEBUG ---
bump_count after the assert = 1
--- with -DNDEBUG ---
bump_count after the assert = 0Same source. Two different behaviours.
Defining NDEBUG before including <assert.h> makes assert expand to nothing at all. Not "to a check that always passes" — to nothing, taking the entire expression with it. bump() is never called in the second build.
The only hint the compiler gave was indirect:
warning: unused function 'bump' [-Wunused-function]— which appears only because nothing else calls bump, and which would be absent in any real program.
assertdoes not disable a check in release builds. It deletes an expression from your program. Anything that expression was doing stops happening.
This is why assert must never contain a call that does work:
assert(initialise_subsystem() == 0); /* the subsystem is never initialised in release */
assert(fclose(f) == 0); /* the file is never closed in release */
assert(i++ < n); /* i is never incremented in release */Each of those is a program that behaves differently depending on a build flag, with no diagnostic.
The rule: the expression inside an assert must be pure — it reads state and changes nothing.
4. The One Thing It Must Never Be Used For
c.ts asks this chapter to name it, and §3 is the reason.
Never use
assertto validate input.
By input I mean anything that can legitimately be wrong without the program containing a bug: command-line arguments, file contents, network data, a value from a caller outside your module, the result of a library call that is allowed to fail.
/* WRONG */
void handle(const char *path)
{
assert(path != NULL); /* a caller may legitimately pass NULL */
FILE *f = fopen(path, "r");
assert(f != NULL); /* a missing file is not a bug */
...
}Three things are wrong with that, in increasing order of severity.
It reports to the wrong audience. Assertion failed: (f != NULL), function handle, file app.c, line 42. is a message for the program's author. A user whose file is missing needs "cannot open /etc/config: No such file or directory" — Chapter 12.8 §5.
It responds in the wrong way. abort is not how a program handles a missing file. There is no cleanup, no useful exit status, and no opportunity to try something else.
And in the build your users run, there is no check at all. This is the part §3 measured. Under NDEBUG the assert is gone, f is not tested, and the next line dereferences a null FILE * — Chapter 7.4 §3's undefined behaviour. The failure mode in release is strictly worse than having written nothing.
The same program with NDEBUG defined ran straight past its assert(b != 0) into a division by zero. Execution continued; what it then did is undefined behaviour and is not reproduced here.
The correct shape is the one Module 12 established:
int handle(const char *path)
{
if (path == NULL) { return -1; } /* a real check */
FILE *f = fopen(path, "r");
if (f == NULL) { /* a real check */
fprintf(stderr, "cannot open %s: %s\n", path, strerror(errno));
return -1;
}
...
}5. _Static_assert: The Compile-Time Version
Some assumptions can be checked before the program runs at all.
#include <limits.h>
_Static_assert(CHAR_BIT == 8, "this code assumes 8-bit bytes");
_Static_assert(sizeof(int) == 4, "this code assumes a 32-bit int");
_Static_assert(sizeof(void *) >= 4, "pointers must be at least 32 bits");Those compile clean here. One that does not hold:
error: static assertion failed due to requirement 'sizeof(int) == 2':
this code assumes a 16-bit intAn error, with your message, at compile time, on the first machine where the assumption is false.
And the property that makes it the right tool for this job:
$ cc -std=c17 -DNDEBUG -c a4.c
error: static assertion failed due to requirement 'sizeof(int) == 2': ...NDEBUG has no effect on it. _Static_assert is a language feature, not the assert macro, so it cannot be compiled away — which is why Chapter 13.3 §7 recommended it specifically for the implementation-defined assumptions a module depends on.
assert | _Static_assert | |
|---|---|---|
| when | run time | compile time |
removed by NDEBUG | yes | no |
| run-time cost | a test, in debug builds | none, ever |
| can test | any expression | integer constant expressions only |
| on failure | abort, status 134 | the build fails |
_Static_assert is C11 and available in C17; C23 spells it static_assert with no header needed. Use it for anything constant: type sizes, alignment, that a structure's size matches a format, that an array and an enumeration agree in length.
enum level { LEVEL_DEBUG, LEVEL_INFO, LEVEL_WARN, LEVEL_COUNT };
static const char *const level_name[LEVEL_COUNT] = { ... };
_Static_assert(sizeof level_name / sizeof level_name[0] == LEVEL_COUNT,
"level_name must have one entry per level");That is Chapter 10.8 §7's count-sentinel gap closed: the chapter measured that adding an enumerator silently leaves a null entry in the table, and this is the line that turns it into a build failure.
6. Writing Good Assertions
One condition per assertion. assert(a && b) tells you only that one of them failed; two assertions tell you which.
Assert the specific thing. assert(n > 0 && n <= MAX) as two assertions beats assert(valid(n)), because the diagnostic shows the expression and valid(n) shows nothing.
Add a message when the condition is not self-explanatory:
assert(count <= capacity && "the table grew without the capacity being updated");Keep it pure. §3.
Assert at the top of a function, where the preconditions are, so a reader sees the contract before the body.
And do not assert what the type system already guarantees. assert(sizeof(int) == sizeof(int)) and checks on values that cannot vary are noise.
One more worth stating because it is a genuine judgement call:
An assertion that has never fired in production is not wasted. It is documentation that is verified on every developer build and every test run, and the one time it fires it will save hours. The cost is a comparison in a build users do not run.
7. The NDEBUG Decision
Whether to define NDEBUG in release builds is a real choice with arguments on both sides, and the common assumption that you obviously should is worth examining.
For defining it: assertions cost a test and a branch each; in a hot loop that is measurable. And an abort in front of a user is a poor failure mode compared with a graceful degradation.
Against defining it: an assertion that fires in production has found a real bug, and abort at the first sign of a violated invariant is often safer than continuing with corrupted state — particularly for anything handling data it did not produce. A program that aborts has not written the wrong answer to a file.
The position this curriculum recommends: keep assertions enabled unless you have measured that they cost something that matters. The usual reason for disabling them is an assumption about performance that nobody checked, and Chapter 13.1's callout is about exactly that habit.
And whichever you choose, the rules in §3 and §4 do not change. If assertions are compiled away, input validation must not be among them; if they are kept, side effects inside them are still a trap waiting for the first person who adds -DNDEBUG to a build file.
8. What You Should Be Able to Say Now
assertwrites a diagnostic and callsabort— measured: "Assertion failed: (b != 0), function divide, file a2.c, line 3.", exit status 134.- It reports the expression, function, file and line — the expression text comes from Chapter 11.6's stringizing, in the library's own macro.
- An assertion states something that is true unless the program has a bug.
NDEBUGdeletes the expression, not just the check — measured: a counter reached 1 without it and 0 with it, in the same program.- So side effects inside an assertion disappear in release, with only an indirect
-Wunused-functionas a hint. - The expression must be pure.
- Never validate input with
assert— wrong audience, wrong response, and no check at all in the build users run. - The test: can this be false in a program with no bugs? Yes → a real check. No → an assertion.
- Both directions are mistakes — a runtime check for an invariant creates an untestable branch that must still be maintained.
_Static_assertis compile-time, costs nothing, andNDEBUGdoes not affect it — measured, it still failed the build under-DNDEBUG.- Use it for type sizes, alignment, and table-versus-enum length — which closes Chapter 10.8 §7's measured gap.
- Keep assertions on unless you have measured a reason not to, and the rules hold either way.
9. Exercises
Exercise 1 — assert or check. For each, say which and why, using §4's question.
the array index is below the count validated by the caller
fopen returned non-NULL
the user supplied at least two arguments
the linked structure's next pointer is NULL at the end
malloc returned non-NULL
an internal enum tag is one of three known values
a length read from a file is within the bufferExercise 2 — find the release bug. Each behaves differently with -DNDEBUG. Say how.
assert(init() == 0);
assert(fclose(f) == 0);
assert(count++ < limit);
assert(validate(input)); /* validate() logs on failure */Exercise 3 — rewrite it. Convert this to use assertions and checks correctly, and say where you drew the boundary.
int load(const char *path, int *out, size_t n) {
assert(path != NULL);
assert(out != NULL);
assert(n > 0);
FILE *f = fopen(path, "r");
assert(f != NULL);
...
}Exercise 4 — write the static assertions. A module requires 8-bit bytes, a 4-byte int, and a struct header of exactly 16 bytes. Write the three lines, and say what each prevents.
Exercise 5 — close the gap. Chapter 10.8 §7 measured a table silently gaining a null entry when an enumerator was added. Write the _Static_assert that turns it into a build failure, and say why assert could not do this job.
Exercise 6 — improve the diagnostics. Rewrite each so a failure says more.
assert(a && b && c);
assert(ok);
assert(n < MAX);Exercise 7 — the NDEBUG argument. Give the case for and against defining NDEBUG in a release build of a program that parses files supplied by other people. State your conclusion and why.
Exercise 8 — audit a function. List what you would assert and what you would check.
size_t copy_field(char *dst, size_t cap, const char *src, size_t field_index) { ... }Say which parameters come from inside your module and which from outside, and how that changes your answer.
10. Summary
assertprints expression, function, file, line and callsabort— status 134.- An assertion states what is true unless the program has a bug.
NDEBUGdeletes the expression — measured, a counter read 1 then 0. Keep assertions pure.- Never validate input with
assert: wrong audience, wrong response, and absent in release. - The test: can this be false without a bug? Yes → check. No → assert.
- Both directions are mistakes; the boundary is the one you drew in Chapter 13.6 §6.
_Static_assertis compile-time, free, and immune toNDEBUG— measured.- Use it for sizes, alignment, and table-versus-enum agreement (10.8 §7's gap).
- One condition per assertion, at the top, with a message when it is not obvious.
- Keep assertions on unless you have measured otherwise.
Everything in this module so far has been preventive: say what you assume, turn on the warnings, pick the types that cannot wrap, draw the boundary where the checking happens. All of it is for code you have not written yet. The situation this module has not addressed is the one you are actually in when things go wrong — a program that already exists, that is already failing, with no idea why, and output that may itself be a symptom rather than evidence. Rereading the source until the error becomes visible is the universal first instinct and it is a poor method: it scales badly, it fails entirely when the cause is nowhere near the symptom, and it is exactly the situation Chapters 9.11 and 13.6 measured as the normal case. The next chapter is about localising a fault by a procedure instead.
Standards & specifications
- Governing standard
- ISO/IEC 9899 (the C language standard)(opens ISO/IEC (JTC 1/SC 22/WG 14) in a new tab)
Defines the C language itself — the type system, expression semantics, object model, storage duration and the behaviour categories a conforming implementation must honour. It is a FAMILY of editions (C89/C90, C99, C11, C17, C23) and this curriculum names the edition wherever a claim depends on one. It does not define any foreign-function interface to a hardware description language, and it does not define platform behaviour: what a memory-mapped access does on real silicon is the implementation's and the device's contract, not this standard's. Working drafts published by WG14 are freely available and are what most engineers read day to day; a draft is not the published standard.
This page also covers RTL structure, verification approach and debugging technique. Those are engineering practice built on the standard, not requirements the standard itself imposes.
Where this fits
Part of the C Programming curriculum.
