Procedural Programming with C · Intermediate · Week 27

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.

By the end of this week you can
  • Name the three kinds of error and say which technique addresses each.
  • Use errno, perror, and strerror correctly, including when to save errno.
  • Decide whether a given check belongs in an assert or 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

KindExampleHandled by
Compile timeSyntax error, type mismatchFixing the code
Link timeundefined referenceLinking the right library — weeks 2, 29
Runtime, programmer errorNull pointer passed where forbidden, index out of rangeassert — it is a bug
Runtime, environmentalFile missing, disk full, allocation failedReturn 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:

FunctionFailure looks like
malloc, calloc, reallocreturns NULL
fopenreturns NULL, sets errno
fclose, fflushreturns EOF
fgetsreturns NULL — check ferror to distinguish from end of file
fread, fwritereturns fewer items than requested
printf, fprintfreturns a negative value
strtol, strtodvia endptr and errno — week 23
remove, renamereturns 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 out

Never 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 forUse a real check for
Preconditions your own code must satisfyAnything derived from user input, files, or the network
Invariants that should be impossible to breakAllocation results and system call results
Documenting an assumption executablyConditions 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 switch warning 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
./errors

What 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

MistakeWhat happensFix
Ignoring a return valueProgram continues on bad stateCheck every call that can fail; mark yours [[nodiscard]].
Reading errno after other callsOverwritten by the reporting codeSave it immediately.
Testing errno without a failure indicationMeaningless — it may be set on successCheck the return value first.
Side effects inside assertDisappears under NDEBUGNever put work in an assertion.
assert on user inputRelease build accepts bad input silentlyReal checks for anything external.
Cleanup duplicated per early returnOne branch forgets a releaseSingle cleanup label.
Uninitialized resource variablesCleanup frees garbageInitialize all to NULL at the top.
Writing output parameters before validatingCaller sees half-written resultsWrite 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.