Skip to main content
C · lesson 18 of 25
C intermediate Lesson 18 of 25

Error Handling in C

Handle errors in C with return codes, errno, perror and strerror, assert vs validation, goto cleanup paths, and the traps of setjmp/longjmp.

By SkillByExample · Updated

Return Value Conventions

C has no exceptions, so a function reports failure through what it returns, and the caller has to check. Two conventions cover most code: return NULL when the result is a pointer, and return 0/-1 with the real result going out through a pointer parameter:

#include <ctype.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Convention 1: return NULL on failure */
char *duplicate_upper(const char *s) {
    if (!s) return NULL;
    size_t n = strlen(s);
    char *result = malloc(n + 1);
    if (!result) return NULL;
    for (size_t i = 0; i <= n; i++) result[i] = (char)toupper((unsigned char)s[i]);
    return result;
}

/* Convention 2: return 0 on success, -1 on failure; result via pointer */
int read_int_from_file(const char *filename, int *out) {
    FILE *fp = fopen(filename, "r");
    if (!fp) return -1;
    int ok = fscanf(fp, "%d", out) == 1;
    fclose(fp);
    return ok ? 0 : -1;
}

int main(void) {
    char *upper = duplicate_upper("hello");
    printf("upper: %s\n", upper ? upper : "(failed)");
    free(upper);
    printf("NULL input: %s\n", duplicate_upper(NULL) ? "ok" : "NULL returned");

    int value;
    if (read_int_from_file("number.txt", &value) == 0) printf("number.txt: %d\n", value);
    if (read_int_from_file("missing.txt", &value) != 0) printf("missing.txt: failed\n");
    return 0;
}
$ printf "42\n" > number.txt
$ gcc -Wall -Wextra -std=c17 -o conventions conventions.c
$ ./conventions
upper: HELLO
NULL input: NULL returned
number.txt: 42
missing.txt: failed

duplicate_upper treats a NULL argument and an out-of-memory failure the same way, so the caller has only one thing to check. The loop runs to i <= n so the terminator is copied as well (toupper('\0') is '\0'). Leave out #include <ctype.h> and GCC 14 refuses to compile the call, with “implicit declaration of function ‘toupper’”.

Error-Code Enums

When the caller needs to know why something failed, return an enum:

#include <stdio.h>
#include <string.h>

typedef enum {
    ERR_OK = 0,
    ERR_NULL_ARG,
    ERR_TOO_LONG,
    ERR_INVALID_CHAR,
} ErrorCode;

const char *error_message(ErrorCode e) {
    switch (e) {
        case ERR_OK:           return "success";
        case ERR_NULL_ARG:     return "null argument";
        case ERR_TOO_LONG:     return "username longer than 8 characters";
        case ERR_INVALID_CHAR: return "username may only contain a-z";
    }
    return "unknown error";
}

ErrorCode validate_username(const char *name) {
    if (!name) return ERR_NULL_ARG;
    if (strlen(name) > 8) return ERR_TOO_LONG;
    for (const char *p = name; *p; p++)
        if (*p < 'a' || *p > 'z') return ERR_INVALID_CHAR;
    return ERR_OK;
}

int main(void) {
    const char *tests[] = {"alice", "bartholomew", "bob_1", NULL};
    for (int i = 0; i < 4; i++) {
        ErrorCode e = validate_username(tests[i]);
        printf("%-12s -> %d (%s)\n", tests[i] ? tests[i] : "(null)", e, error_message(e));
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o codes codes.c
$ ./codes
alice        -> 0 (success)
bartholomew  -> 2 (username longer than 8 characters)
bob_1        -> 3 (username may only contain a-z)
(null)       -> 1 (null argument)

Making ERR_OK equal 0 lets callers write if (validate_username(s)) { /* failed */ }. Leaving default: out of the switch means -Wswitch flags error_message if someone adds a new code and forgets its message.

errno, perror and strerror

Library functions that fail usually also set errno, a per-thread integer holding the reason. strerror turns the code into text, and perror prints your label followed by that text:

#include <errno.h>
#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

int main(void) {
    FILE *fp = fopen("/nonexistent/path/file.txt", "r");
    if (!fp) {
        int saved = errno;              /* save it before anything else runs */
        printf("errno = %d (ENOENT is %d)\n", saved, ENOENT);
        printf("strerror: %s\n", strerror(saved));
        errno = saved;
        perror("fopen");                /* "label: message" on stderr */
    }

    errno = 0;
    long big = strtol("99999999999999999999", NULL, 10);
    printf("strtol: %ld, errno = %s\n", big, errno == ERANGE ? "ERANGE" : "0");

    errno = 0;
    double r = sqrt(-1.0);
    printf("sqrt(-1) = %f, errno = %s\n", r, errno == EDOM ? "EDOM" : "0");
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o errnodemo errnodemo.c -lm
$ ./errnodemo
errno = 2 (ENOENT is 2)
strerror: No such file or directory
fopen: No such file or directory
strtol: 9223372036854775807, errno = ERANGE
sqrt(-1) = -nan, errno = EDOM

strtol and sqrt show the other half of the pattern: set errno = 0 before the call, because these functions don’t clear it on success. strtol returned LONG_MAX and set ERANGE, while sqrt(-1) returned NaN and set EDOM (domain error). The numeric codes (2 for ENOENT) are the same on Linux and macOS but not guaranteed by C, so always compare against the named constants. The program saves errno straight away because printf itself is allowed to change it.

errno Is Only Meaningful After a Failure

A successful call is not required to reset errno, so an old error can still be sitting there:

#include <errno.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    FILE *fp = fopen("missing.txt", "r");       /* fails: sets errno */
    if (fp) fclose(fp);

    FILE *ok = fopen("number.txt", "r");        /* succeeds */
    printf("second fopen %s, errno still says: %s\n",
           ok ? "succeeded" : "failed", strerror(errno));
    if (ok) fclose(ok);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o stale stale.c
$ ./stale
second fopen succeeded, errno still says: No such file or directory

The second fopen worked, yet errno still reports the first failure. Code that checks errno instead of the return value would wrongly report an error here. Check the return value first, and read errno only once it says the call failed.

Defensive Programming: Validation vs. assert

Bad data from outside the program gets a proper error return. A condition that can only fail if your code has a bug gets an assert:

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

/* Runtime input: validate and report, never assert */
int parse_age(const char *s, int *out_age) {
    if (!s || !out_age) return -1;
    char *end;
    errno = 0;
    long age = strtol(s, &end, 10);
    if (errno != 0)            return -1;   /* overflow */
    if (end == s)              return -1;   /* no digits */
    if (*end != '\0')          return -1;   /* trailing junk like "25abc" */
    if (age < 0 || age > 150)  return -1;   /* out of range */
    *out_age = (int)age;
    return 0;
}

/* Programmer error: a caller must never pass 0 */
double safe_divide(double a, double b) {
    assert(b != 0.0 && "divisor must not be zero");
    return a / b;
}

int main(void) {
    const char *inputs[] = {"42", "25abc", "", "-3", "200"};
    for (int i = 0; i < 5; i++) {
        int age;
        if (parse_age(inputs[i], &age) == 0) printf("\"%s\" -> %d\n", inputs[i], age);
        else                                 printf("\"%s\" -> rejected\n", inputs[i]);
    }
    printf("10 / 4 = %.2f\n", safe_divide(10, 4));
    printf("10 / 0 = %.2f\n", safe_divide(10, 0));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o defensive defensive.c
$ ./defensive
"42" -> 42
"25abc" -> rejected
"" -> rejected
"-3" -> rejected
"200" -> rejected
10 / 4 = 2.50
defensive: defensive.c:22: safe_divide: Assertion `b != 0.0 && "divisor must not be zero"' failed.
Aborted (core dumped)
$ gcc -Wall -Wextra -std=c17 -DNDEBUG -o defensive defensive.c
$ ./defensive
"42" -> 42
"25abc" -> rejected
"" -> rejected
"-3" -> rejected
"200" -> rejected
10 / 4 = 2.50
10 / 0 = inf

parse_age rejected four bad inputs, each for a different reason, without crashing. The failed assert printed the file, line, function and the condition’s text, then called abort() (exit status 134). The && "message" trick puts a readable explanation into that output. Built with -DNDEBUG, as release builds often are, the assertion disappears completely and 10 / 0 quietly produces inf. That’s why assert must never guard against input the outside world can supply.

Cleanup on Every Error Path with goto

A function that acquires several resources must release all of them on every failure path. A single cleanup: label does it once:

#include <stdio.h>
#include <stdlib.h>

typedef struct { int *data; size_t n; } IntArray;

/* File format: a count, then that many integers */
int load_ints(const char *filename, IntArray *out) {
    FILE *fp = NULL;
    int *buf = NULL;
    int rc = -1, count;

    fp = fopen(filename, "r");
    if (!fp) { perror(filename); goto cleanup; }
    if (fscanf(fp, "%d", &count) != 1 || count <= 0) {
        fprintf(stderr, "%s: bad count\n", filename);
        goto cleanup;
    }
    buf = malloc((size_t)count * sizeof *buf);
    if (!buf) { perror("malloc"); goto cleanup; }
    for (int i = 0; i < count; i++) {
        if (fscanf(fp, "%d", &buf[i]) != 1) {
            fprintf(stderr, "%s: read error at index %d\n", filename, i);
            goto cleanup;
        }
    }
    out->data = buf;
    out->n = (size_t)count;
    buf = NULL;          /* ownership moved to *out: don't free below */
    rc = 0;
cleanup:
    free(buf);
    if (fp) fclose(fp);
    return rc;
}

int main(int argc, char *argv[]) {
    for (int i = 1; i < argc; i++) {
        IntArray arr;
        if (load_ints(argv[i], &arr) == 0) {
            printf("%s: %zu ints, last = %d\n", argv[i], arr.n, arr.data[arr.n - 1]);
            free(arr.data);
        }
    }
    return 0;
}
$ printf "4\n10 20 30 40\n" > good.txt
$ printf "4\n10 20 x\n" > truncated.txt
$ gcc -Wall -Wextra -std=c17 -o cleanup cleanup.c
$ ./cleanup good.txt truncated.txt missing.txt
good.txt: 4 ints, last = 40
truncated.txt: read error at index 2
missing.txt: No such file or directory

Three runs took three different exits through the same cleanup code. truncated.txt failed after both the file and the buffer existed, and both were released. missing.txt failed before either existed, and free(NULL) plus the if (fp) check made that safe. On success, buf = NULL hands ownership to the caller, so the shared cleanup code doesn’t free memory that was just returned.

setjmp and longjmp

setjmp records the current point of execution and returns 0. A later longjmp from any function deeper in the call stack jumps straight back, making setjmp appear to return a second time with a different value:

#include <setjmp.h>
#include <stdio.h>

static jmp_buf error_jump;

void deep_function(int level) {
    printf("entering level %d\n", level);
    if (level == 0) {
        printf("fatal error at level 0, jumping out\n");
        longjmp(error_jump, 42);          /* setjmp now "returns" 42 */
    }
    deep_function(level - 1);
    printf("returning from level %d\n", level);   /* never printed */
}

int main(void) {
    int err = setjmp(error_jump);          /* 0 the first time */
    if (err == 0) {
        deep_function(3);
        printf("completed normally\n");
    } else {
        printf("caught error code %d back in main\n", err);
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o jump jump.c
jump.c: In function 'deep_function':
jump.c:6:6: warning: infinite recursion detected [-Winfinite-recursion]
    6 | void deep_function(int level) {
      |      ^~~~~~~~~~~~~
jump.c:12:5: note: recursive call
   12 |     deep_function(level - 1);
      |     ^~~~~~~~~~~~~~~~~~~~~~~~
$ ./jump
entering level 3
entering level 2
entering level 1
entering level 0
fatal error at level 0, jumping out
caught error code 42 back in main

Four stack frames were abandoned in one step. No “returning from level” line ever printed, and nothing in those frames got a chance to clean up. Even GCC was misled. Since longjmp never returns, the only way out of deep_function that it can see is the recursive call, so it warns about infinite recursion. The recursion is actually bounded by level, but the warning is a fair hint that this control flow is hard to reason about.

The other trap is local variables. Any non-volatile local changed after setjmp has an indeterminate value after the jump:

#include <setjmp.h>
#include <stdio.h>

static jmp_buf env;

void fail(void) { longjmp(env, 1); }

int main(void) {
    int plain = 1;
    volatile int marked = 1;
    if (setjmp(env) == 0) {
        plain = 2;
        marked = 2;
        fail();
    }
    printf("plain = %d, volatile = %d\n", plain, marked);
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o jumpvol jumpvol.c
$ ./jumpvol
plain = 2, volatile = 2
$ gcc -Wall -Wextra -std=c17 -O2 -o jumpvol jumpvol.c
$ ./jumpvol
plain = 1, volatile = 2

At -O2, plain kept its value in a CPU register, and longjmp restored the registers saved by setjmp, so the update to 2 was lost. Nothing warned about it. volatile forced marked to live in memory, so it survived. Explicit error returns avoid this whole class of problem, which is why they’re the normal choice.

Practice

  1. Write int parse_int(const char *s, int *out) that returns 0 on success, -1 for bad or trailing characters, and -2 for values that don’t fit in an int.
Solution
#include <errno.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>

int parse_int(const char *s, int *out) {
    char *end;
    errno = 0;
    long v = strtol(s, &end, 10);
    if (end == s || *end != '\0') return -1;
    if (errno == ERANGE || v < INT_MIN || v > INT_MAX) return -2;
    *out = (int)v;
    return 0;
}

int main(void) {
    const char *tests[] = {"123", "-45", "12x", "3000000000"};
    for (int i = 0; i < 4; i++) {
        int v;
        int rc = parse_int(tests[i], &v);
        if (rc == 0) printf("%-11s ok: %d\n", tests[i], v);
        else         printf("%-11s error %d\n", tests[i], rc);
    }
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p1 p1.c
$ ./p1
123         ok: 123
-45         ok: -45
12x         error -1
3000000000  error -2

3000000000 fits in a 64-bit long, so strtol doesn’t set ERANGE. The explicit INT_MAX check is what catches it. Distinct negative codes let the caller tell “not a number” from “too big”.

  1. Print the strerror text for EPERM, ENOENT, EACCES, EEXIST, EINVAL and ENOSPC, with their numbers.
Solution
#include <errno.h>
#include <stdio.h>
#include <string.h>

int main(void) {
    int codes[] = {EPERM, ENOENT, EACCES, EEXIST, EINVAL, ENOSPC};
    for (int i = 0; i < 6; i++) printf("%3d: %s\n", codes[i], strerror(codes[i]));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p2 p2.c
$ ./p2
  1: Operation not permitted
  2: No such file or directory
 13: Permission denied
 17: File exists
 22: Invalid argument
 28: No space left on device

These numbers are Linux’s. Only the names are portable (EDOM, ERANGE and EILSEQ are the only ones standard C itself defines), so code should always compare against the constants.

  1. Write copy_file(src, dst) using the goto cleanup pattern, reporting failures with perror. Test it with a good source, a missing source, and an unwritable destination.
Solution
#include <stdio.h>
#include <stdlib.h>
#include <string.h>

/* Copy src to dst; returns 0, or -1 with a message on any failure */
int copy_file(const char *src, const char *dst) {
    int rc = -1;
    FILE *in = NULL, *out = NULL;
    char *buf = NULL;

    in = fopen(src, "rb");
    if (!in) { perror(src); goto cleanup; }
    out = fopen(dst, "wb");
    if (!out) { perror(dst); goto cleanup; }
    buf = malloc(4096);
    if (!buf) { perror("malloc"); goto cleanup; }

    size_t n;
    while ((n = fread(buf, 1, 4096, in)) > 0) fwrite(buf, 1, n, out);
    rc = 0;
cleanup:
    free(buf);
    if (out && fclose(out) != 0) rc = -1;
    if (in) fclose(in);
    return rc;
}

int main(void) {
    printf("copy 1: %d\n", copy_file("number.txt", "copy.txt"));
    printf("copy 2: %d\n", copy_file("missing.txt", "copy2.txt"));
    printf("copy 3: %d\n", copy_file("number.txt", "/nonexistent/out.txt"));
    return 0;
}
$ gcc -Wall -Wextra -std=c17 -o p3 p3.c
$ ./p3
copy 1: 0
missing.txt: No such file or directory
copy 2: -1
/nonexistent/out.txt: No such file or directory
copy 3: -1

In the third case the source was already open when the destination failed, and the cleanup block closed it. A failing fclose on the output also turns a “success” into an error, since that’s where a delayed write failure shows up.

  1. In jumpvol.c, what happens at -O2 if you also mark plain as volatile? Predict, then check.
Solution

Change the declaration to volatile int plain = 1; and rebuild:

$ gcc -Wall -Wextra -std=c17 -O2 -o jumpvol jumpvol.c
$ ./jumpvol
plain = 2, volatile = 2

With both variables volatile, both updates survive the jump, because neither can be kept only in a register. The C standard makes exactly this guarantee: after longjmp, a local changed since setjmp has a reliable value only if it is volatile.

Frequently Asked Questions

Why doesn't C have exceptions?
C was designed for simplicity and minimal runtime machinery, and exceptions need stack unwinding and hidden control flow. Instead, C functions report failure through their return value (and often errno), and the caller checks it explicitly. Error paths are visible in the code, at the cost of having to remember every check.
Is setjmp/longjmp a good substitute for exceptions?
Rarely. longjmp jumps straight back to the setjmp point, skipping every cleanup step in between, so memory and file handles acquired on the way are leaked. Local variables changed after setjmp must also be volatile or their values are indeterminate afterwards. It fits narrow cases such as an interpreter's top-level error recovery.
What should a function return to indicate an error?
Common conventions are NULL for pointer-returning functions, 0 for success and -1 (or a negative code) for failure in int-returning functions, or a dedicated error-code enum for APIs with several failure kinds. Results then come back through pointer parameters. Pick one convention per codebase and apply it everywhere.
When should I use assert instead of returning an error?
assert is for programming errors, conditions that can only be false if the code itself has a bug, such as a function called with an argument its contract forbids. It aborts the program, and it is compiled out entirely when NDEBUG is defined. Anything caused by the outside world, such as user input, missing files or full disks, needs a real error return instead.