C · 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
- Write
int parse_int(const char *s, int *out)that returns0on success,-1for bad or trailing characters, and-2for values that don’t fit in anint.
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”.
- Print the
strerrortext forEPERM,ENOENT,EACCES,EEXIST,EINVALandENOSPC, 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.
- Write
copy_file(src, dst)using thegoto cleanuppattern, reporting failures withperror. 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.
- In
jumpvol.c, what happens at-O2if you also markplainasvolatile? 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.