Error Handling Strategies
C has no exceptions. Every failure travels back to the caller as a return value, and every caller must look. What separates robust C from fragile C is not cleverness but consistency: one convention, applied everywhere, with exactly one cleanup path per function.
- Name the three kinds of error and say which technique addresses each.
- Use
errno,perror, andstrerrorcorrectly, including when to saveerrno. - Decide whether a given check belongs in an
assertor in the shipped code. - Write a function that acquires several resources and releases exactly the ones it got.
- Define an error contract that a whole module follows.
1Three kinds of error
| Kind | Example | Handled by |
|---|---|---|
| Compile time | Syntax error, type mismatch | Fixing the code |
| Link time | undefined reference | Linking the right library — weeks 2, 29 |
| Runtime, programmer error | Null pointer passed where forbidden, index out of range | assert — it is a bug |
| Runtime, environmental | File missing, disk full, allocation failed | Return a status; the caller decides |
The last two are often confused, and the distinction drives every decision in this session. A file that does not exist is not a bug — it is a legitimate state of the world your program must handle. A null pointer passed to a function that documented it must not be null is a bug, and no amount of runtime handling will make the program correct.
Environmental failures get error handling. Programmer errors get assertions.
2Checking return values
Every standard library function that can fail says so. The convention varies, which is why a table is worth keeping in view:
| Function | Failure looks like |
|---|---|
malloc, calloc, realloc | returns NULL |
fopen | returns NULL, sets errno |
fclose, fflush | returns EOF |
fgets | returns NULL — check ferror to distinguish from end of file |
fread, fwrite | returns fewer items than requested |
printf, fprintf | returns a negative value |
strtol, strtod | via endptr and errno — week 23 |
remove, rename | returns non-zero, sets errno |
There is no way to be told you forgot. GCC's __attribute__((warn_unused_result)) and C23's [[nodiscard]] let you mark your own functions so callers are warned:
[[nodiscard]] bool buffer_append(Buffer *b, const char *text); /* C23 */Use it on anything whose result must not be ignored. It is the only mechanism C offers for enforcing that a caller checks.
3errno
errno is a modifiable lvalue holding the last error code set by a library function. Three rules govern it.
It is only meaningful after a function has reported failure. Library functions may set it on success too, so testing it without first seeing a failure indication is meaningless.
Set it to 0 before calls that need it. Required for strtol and the mathematics functions, which report range errors only through errno.
Save it immediately. Any intervening library call can overwrite it — including the fprintf you are using to report the problem:
if (fopen(path, "r") == NULL) {
int saved = errno; /* capture first */
fprintf(stderr, "opening %s: %s\n", path, strerror(saved));
return saved;
}Reporting it
perror("data.txt");
/* prints: data.txt: No such file or directory */
fprintf(stderr, "%s: %s\n", path, strerror(errno));
/* the same text, under your control */perror is convenient and always writes to stderr. strerror returns the message so you can place it in a larger sentence, log it, or send it elsewhere — at the cost of returning a pointer to a static buffer, which the next call overwrites.
4Assertions
#include <assert.h>
void buffer_append(Buffer *b, const char *text)
{
assert(b != NULL); /* the caller's obligation */
assert(text != NULL);
…
}If the condition is false, assert prints the expression, the file, and the line, and calls abort. Defining NDEBUG removes every assertion:
gcc -DNDEBUG -O2 … # assertions compiled outNever put a side effect in an assertion. assert(buffer_append(b, "x")); works in a debug build and silently does nothing in a release build, because the whole expression disappears. This produces a program that works while you are testing it and fails when you ship it — the worst possible failure mode.
Use assert for | Use a real check for |
|---|---|
| Preconditions your own code must satisfy | Anything derived from user input, files, or the network |
| Invariants that should be impossible to break | Allocation results and system call results |
| Documenting an assumption executably | Conditions the caller is allowed to hit |
static_assert
static_assert(sizeof(int) >= 4, "this code assumes at least 32-bit int");
static_assert(sizeof(Record) == 48, "Record layout changed unexpectedly");Checked at compile time, costs nothing at runtime, and cannot be disabled. Use it for assumptions about sizes, alignments, and enumeration counts. It is spelled _Static_assert before C23 unless you include <assert.h>.
5The goto cleanup idiom
A function that acquires several resources must release exactly the ones it managed to acquire, on every exit path. Written with early returns, the releases multiply:
/* The version everyone writes first */
bool process(const char *in_path, const char *out_path)
{
FILE *in = fopen(in_path, "r");
if (in == NULL) return false;
FILE *out = fopen(out_path, "w");
if (out == NULL) { fclose(in); return false; }
char *buffer = malloc(BUFSIZE);
if (buffer == NULL) { fclose(out); fclose(in); return false; }
if (!do_work(in, out, buffer)) {
free(buffer); fclose(out); fclose(in); return false;
}
free(buffer); fclose(out); fclose(in);
return true;
}fclose(in) appears four times. Add a fourth resource and it appears five. Every duplicate is a place to forget one — and the error paths are exactly the ones your tests do not cover.
The idiom that fixes it — and that the Linux kernel, SQLite, and most serious C libraries use throughout:
bool process(const char *in_path, const char *out_path)
{
bool ok = false;
FILE *in = NULL;
FILE *out = NULL;
char *buffer = NULL;
in = fopen(in_path, "r");
if (in == NULL) {
perror(in_path);
goto cleanup;
}
out = fopen(out_path, "w");
if (out == NULL) {
perror(out_path);
goto cleanup;
}
buffer = malloc(BUFSIZE);
if (buffer == NULL) {
goto cleanup;
}
if (!do_work(in, out, buffer)) {
goto cleanup;
}
ok = true;
cleanup:
free(buffer); /* free(NULL) is safe */
if (out != NULL && fclose(out) != 0) { /* the flush can fail */
ok = false;
}
if (in != NULL) {
fclose(in);
}
return ok;
}What makes it work: every resource variable is initialized to a null value at the top, so the single cleanup block can unconditionally attempt to release all of them. free(NULL) is defined to do nothing, and the NULL guards handle the file pointers. There is exactly one exit, and adding a fifth resource means adding one acquisition and one release.
For several resources with distinct cleanup, use cascading labels in reverse order of acquisition:
…
goto cleanup_all;
cleanup_all:
free(buffer);
cleanup_out:
fclose(out);
cleanup_in:
fclose(in);
return ok;Each failure jumps to the label that releases only what has been acquired so far. This is the one place where week 10's warning about goto does not apply — it is the standard C solution to a problem the language gives no other tool for.
6An error contract for a module
Individual checks are not enough. A module needs one stated convention, so callers do not have to learn a new one per function.
/* csv.h — error contract
*
* Every function returns a CsvStatus. CSV_OK means success and
* output parameters have been written. Any other value means failure,
* and output parameters are untouched.
*
* On CSV_ERRNO the caller may inspect errno for details.
* No function frees anything the caller passed in.
* csv_last_message() returns a human-readable description of the
* most recent failure on this thread.
*/
typedef enum {
CSV_OK = 0,
CSV_ERRNO, /* a system call failed; see errno */
CSV_BAD_FORMAT, /* the input was malformed */
CSV_TOO_LONG, /* a line exceeded the limit */
CSV_NO_MEMORY
} CsvStatus;
const char *csv_status_name(CsvStatus s);Four decisions worth copying:
- A named enumeration, not bare integers. Week 22's
switchwarning then tells every caller when you add a status. - Success is zero, matching the rest of C and letting
if (status != CSV_OK)read naturally. - Output parameters are untouched on failure, so a caller cannot accidentally use a half-written result.
- Ownership is stated once, for the whole module, rather than per function.
Write this comment block before the functions, not after. It is the part of the interface that the signatures cannot express.
7Worked example: one contract, one cleanup path
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <errno.h>
#include <assert.h>
#include <stdbool.h>
#define LINE_MAX_LEN 256
/* ---------- the contract ---------- */
typedef enum {
FC_OK = 0,
FC_ERRNO,
FC_BAD_FORMAT,
FC_TOO_LONG,
FC_NO_MEMORY
} FcStatus;
static const char *fc_status_name(FcStatus s)
{
switch (s) { /* no default: see week 22 */
case FC_OK: return "ok";
case FC_ERRNO: return "system error";
case FC_BAD_FORMAT: return "malformed input";
case FC_TOO_LONG: return "line too long";
case FC_NO_MEMORY: return "out of memory";
}
return "unknown";
}
/* ---------- the function with four resources ---------- */
/* Copies in_path to out_path, doubling every number it finds.
Returns FC_OK on success. On failure, out_path may exist but
its contents are unspecified; nothing the caller owns is freed. */
static FcStatus double_numbers(const char *in_path, const char *out_path,
long *lines_out)
{
assert(in_path != NULL); /* programmer error if violated */
assert(out_path != NULL);
assert(lines_out != NULL);
FcStatus status = FC_ERRNO; /* pessimistic default */
FILE *in = NULL;
FILE *out = NULL;
char *buffer = NULL;
long lines = 0;
in = fopen(in_path, "r");
if (in == NULL) {
goto cleanup;
}
out = fopen(out_path, "w");
if (out == NULL) {
goto cleanup;
}
buffer = malloc(LINE_MAX_LEN);
if (buffer == NULL) {
status = FC_NO_MEMORY;
goto cleanup;
}
while (fgets(buffer, LINE_MAX_LEN, in) != NULL) {
lines++;
if (strchr(buffer, '\n') == NULL && !feof(in)) {
status = FC_TOO_LONG;
goto cleanup;
}
buffer[strcspn(buffer, "\n")] = '\0';
if (buffer[0] == '\0') {
continue;
}
errno = 0;
char *end;
long value = strtol(buffer, &end, 10);
if (end == buffer || *end != '\0' || errno == ERANGE) {
status = FC_BAD_FORMAT;
goto cleanup;
}
if (fprintf(out, "%ld\n", value * 2) < 0) {
status = FC_ERRNO;
goto cleanup;
}
}
if (ferror(in)) {
status = FC_ERRNO;
goto cleanup;
}
*lines_out = lines; /* written only on success */
status = FC_OK;
cleanup:
free(buffer); /* free(NULL) is safe */
if (out != NULL && fclose(out) != 0 && status == FC_OK) {
status = FC_ERRNO; /* the flush failed */
}
if (in != NULL) {
fclose(in);
}
return status;
}
/* ---------- helper to build test inputs ---------- */
static bool make_file(const char *path, const char *contents)
{
FILE *f = fopen(path, "w");
if (f == NULL) {
return false;
}
fputs(contents, f);
return fclose(f) == 0;
}
static void try_case(const char *label, const char *contents)
{
const char *in = "fc_in.txt";
const char *out = "fc_out.txt";
if (contents != NULL && !make_file(in, contents)) {
perror("creating input");
return;
}
long lines = -1;
FcStatus s = double_numbers(contents == NULL ? "does_not_exist.txt" : in,
out, &lines);
printf(" %-22s -> %-16s", label, fc_status_name(s));
if (s == FC_OK) {
printf("(%ld lines)\n", lines);
} else if (s == FC_ERRNO) {
printf("(%s)\n", strerror(errno));
} else {
printf("(lines_out untouched: %ld)\n", lines);
}
remove(in);
remove(out);
}
int main(void)
{
puts("== one contract, every failure path exercised ==");
try_case("normal input", "1\n2\n3\n");
try_case("blank lines", "1\n\n2\n");
try_case("not a number", "1\nabc\n");
try_case("trailing rubbish", "1\n12xyz\n");
try_case("out of range", "99999999999999999999\n");
try_case("missing file", NULL);
puts("\n== errno must be saved before reporting ==");
errno = 0;
FILE *f = fopen("definitely_not_here.txt", "r");
if (f == NULL) {
int saved = errno;
fprintf(stderr, " captured errno = %d\n", saved);
fprintf(stderr, " message: %s\n", strerror(saved));
printf(" errno after the fprintf calls is now %d\n", errno);
puts(" which is why the value is captured immediately");
}
puts("\n== assert is for programmer error, not user error ==");
puts(" double_numbers asserts its pointers are non-null:");
puts(" that is the caller's contract, not a runtime condition.");
puts(" A missing file is handled; a null path is a bug.");
puts("\n== static_assert: checked at compile time ==");
static_assert(sizeof(long) >= 4, "long must be at least 32 bits");
puts(" sizeof(long) >= 4 verified before the program ran");
return EXIT_SUCCESS;
}gcc -std=c17 -Wall -Wextra -g -fsanitize=address -o errors errors.c
./errorsWhat the design buys
Every failure path is exercised and none of them leaks. Run it under the leak checker and there is no output — including on the paths that failed after opening two files and allocating a buffer. That is the point of the single cleanup block.
lines_out is untouched on failure. The output shows -1 for every failing case, because the assignment happens only on the success path. A caller that ignores the status still cannot mistake stale data for a result.
Adding a resource is one line in two places. Try it: add a second buffer, initialize it to NULL, allocate it after the first, and add one free to the cleanup block. Compare that with the early-return version, where you would edit five places.
Two experiments
Break the assertion. Call double_numbers(NULL, "out.txt", &lines):
errors: errors.c:44: double_numbers: Assertion `in_path != NULL' failed.
Aborted (core dumped)Now rebuild with -DNDEBUG and the same call segfaults inside fopen instead. The assertion did not make the program correct; it made the bug obvious at the boundary where it was introduced.
Remove the goto and write it with early returns. Then delete one fclose(in) from one branch — a single-character-scale mistake — and run under the leak checker with an input that triggers that branch. The leak is reported; the version with one cleanup path had no branch in which to make the mistake.
8Common mistakes
| Mistake | What happens | Fix |
|---|---|---|
| Ignoring a return value | Program continues on bad state | Check every call that can fail; mark yours [[nodiscard]]. |
Reading errno after other calls | Overwritten by the reporting code | Save it immediately. |
Testing errno without a failure indication | Meaningless — it may be set on success | Check the return value first. |
Side effects inside assert | Disappears under NDEBUG | Never put work in an assertion. |
assert on user input | Release build accepts bad input silently | Real checks for anything external. |
| Cleanup duplicated per early return | One branch forgets a release | Single cleanup label. |
| Uninitialized resource variables | Cleanup frees garbage | Initialize all to NULL at the top. |
| Writing output parameters before validating | Caller sees half-written results | Write only on the success path. |
9Check yourself
When is assert the right tool and when is it the wrong one?
Right for conditions that should be impossible if your code is correct — preconditions your own callers must satisfy, invariants within a module. Wrong for anything the program can legitimately encounter: a missing file, a malformed input line, a failed allocation. Assertions vanish under NDEBUG, so relying on one to validate external input means the release build silently accepts it.
Why must errno be saved immediately after a failure?
Because any subsequent library call may overwrite it — including the fprintf you use to report the problem. By the time you read it, the value can describe an unrelated event. Copy it into a local variable in the same breath as detecting the failure.
Why does the goto cleanup idiom initialize every resource to NULL first?
So the single cleanup block can release all of them unconditionally, regardless of how far the function got. free(NULL) is defined to do nothing and a NULL check guards the rest, so one block correctly handles every failure point. Without the initialization, cleanup would free indeterminate values.
What is wrong with duplicating cleanup code in each early return?
Each duplicate is an independent opportunity to forget one release, and error paths are precisely the paths least likely to be tested. Adding a fifth resource means editing every branch. One cleanup label makes correctness structural rather than a matter of vigilance.
Why should a module define one error contract rather than per-function conventions?
Because callers otherwise have to learn and remember a different convention for every function, and mixed conventions produce unchecked calls. A single documented rule — what the status values mean, whether output parameters are written on failure, who owns what — makes correct use the obvious use, and a named enumeration lets the compiler warn callers when the set of statuses grows.
10Where this leads
You can now write a function that is correct on every path. Week 28 scales that up to a program: splitting code across files, deciding what a header exposes and what stays hidden, and turning the growable array of week 19 into a module with a public interface and a private implementation — the last step before it becomes a library in week 29.