Error Handling
C has no exceptions. Functions report failure through their return values — a special value like NULL or -1, a status code — and often set errno to say why. Checking those results consistently is what separates robust C programs from fragile ones.
Checking return values
Most library functions signal failure in their return value. Know the convention for each one you use:
| Function | On failure returns |
|---|---|
fopen, malloc, calloc, realloc | NULL |
fclose, fflush, remove, rename | Non-zero (EOF for fclose) |
scanf, fscanf, sscanf | Fewer items than expected, or EOF |
fgets | NULL (at end of input or on error) |
fread, fwrite | Fewer items than requested |
printf, fprintf | A negative number |
strtol, strtod | Sets errno to ERANGE; check the end pointer for junk |
errno, perror and strerror
Many functions set the global errno (from <errno.h>) to a code describing the failure. perror prints your message plus the system's description; strerror returns the description as a string:
#include <stdio.h> #include <errno.h> #include <string.h> int main(void) { FILE *f = fopen("no-such-folder/settings.ini", "r"); if (f == NULL) { int saved = errno; // save it before calling anything else perror("could not open settings"); fprintf(stderr, "error code %d: %s\n", saved, strerror(saved)); return 1; } fclose(f); return 0; }
could not open settings: No such file or directory error code 2: No such file or directory
errno is only meaningful right after a function has reported failure — successful calls may leave any value in it — so check the return value first, then read errno straight away.
Designing functions that can fail
Choose one convention and use it throughout a project. Common choices:
- Return a pointer,
NULLon failure — for functions that create something. - Return
boolor0/-1, with results through pointer parameters (lesson 34). - Return an error-code enum — when callers need to tell different failures apart.
#include <stdio.h> #include <string.h> typedef enum { PARSE_OK = 0, PARSE_EMPTY, PARSE_TOO_LONG, PARSE_BAD_CHARACTER, } ParseError; static const char *const parse_messages[] = { [PARSE_OK] = "ok", [PARSE_EMPTY] = "username is empty", [PARSE_TOO_LONG] = "username is longer than 12 characters", [PARSE_BAD_CHARACTER] = "username may only contain letters, digits and _", }; ParseError check_username(const char *s) { size_t len = strlen(s); if (len == 0) return PARSE_EMPTY; if (len > 12) return PARSE_TOO_LONG; for (size_t i = 0; i < len; i++) { char c = s[i]; if (!((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '_')) { return PARSE_BAD_CHARACTER; } } return PARSE_OK; } int main(void) { const char *names[] = { "sam_r", "", "a_very_long_username", "sam rivera" }; for (int i = 0; i < 4; i++) { ParseError e = check_username(names[i]); printf("%-22s %s\n", names[i], parse_messages[e]); } return 0; }
sam_r ok
username is empty
a_very_long_username username is longer than 12 characters
sam rivera username may only contain letters, digits and _Cleaning up on failure
When a function acquires several resources, every failure path must release what was already acquired. The goto cleanup pattern from lesson 20 keeps that manageable:
#include <stdio.h> #include <stdlib.h> int copy_file(const char *from, const char *to) { int result = -1; FILE *in = NULL, *out = NULL; char *buffer = NULL; in = fopen(from, "rb"); if (in == NULL) { perror(from); goto cleanup; } out = fopen(to, "wb"); if (out == NULL) { perror(to); goto cleanup; } buffer = malloc(4096); if (buffer == NULL) { perror("malloc"); goto cleanup; } size_t n; while ((n = fread(buffer, 1, 4096, in)) > 0) { if (fwrite(buffer, 1, n, out) != n) { perror("write"); goto cleanup; } } if (ferror(in)) { perror("read"); goto cleanup; } result = 0; cleanup: free(buffer); if (out != NULL && fclose(out) != 0) result = -1; if (in != NULL) fclose(in); return result; } int main(void) { FILE *f = fopen("original.txt", "w"); if (f == NULL) return 1; fputs("important data\n", f); fclose(f); printf("copy 1: %s\n", copy_file("original.txt", "backup.txt") == 0 ? "ok" : "failed"); printf("copy 2: %s\n", copy_file("missing.txt", "backup2.txt") == 0 ? "ok" : "failed"); return 0; }
copy 1: ok missing.txt: No such file or directory copy 2: failed
exit and atexit
exit(status) ends the program from anywhere: it flushes and closes open files and runs functions registered with atexit. It's fine for small tools; in libraries and larger programs, return errors to the caller instead so it can decide what to do.
#include <stdio.h> #include <stdlib.h> void goodbye(void) { printf("cleaning up before exit\n"); } void must_be_positive(int n) { if (n <= 0) { fprintf(stderr, "fatal: expected a positive number, got %d\n", n); exit(EXIT_FAILURE); } } int main(void) { atexit(goodbye); must_be_positive(5); printf("5 is fine\n"); must_be_positive(-2); printf("never printed\n"); return EXIT_SUCCESS; }
5 is fine fatal: expected a positive number, got -2 cleaning up before exit
assert: checking assumptions
assert(condition) from <assert.h> stops the program with a message if the condition is false. Use it for things that should never happen if your code is correct — not for bad user input. Assertions are removed when you compile with -DNDEBUG, so they must not have side effects.
#include <assert.h> double average(const double *values, size_t n) { assert(values != NULL && n > 0); // a caller bug, not a user error ... }
Try it yourself
Write read_config_number, which opens a file, reads one integer, and returns a distinct error code for "file missing", "no number" and success.
250
limit.txt: error 0, limit 250 missing.txt: error 1, limit still 250