Error Handling
How C reports errors without exceptions: return values, NULL and EOF, errno with perror and strerror, exit status, the goto cleanup pattern and assert.
C has no exceptions
In Python, Java or C#, an operation that cannot be carried out
throws an exception: normal execution stops, and the
runtime searches up the call stack for a handler. C has no such
mechanism. A C function that fails simply returns, and the only sign
of trouble is a value the caller has to check. If the caller does
not check, the program carries on with whatever it got — an
uninitialized number, a NULL pointer, half a file.
Every library and code base therefore picks conventions, and the C standard library itself uses several:
| Convention | Failure looks like | Examples |
|---|---|---|
| Status code | non-zero (or negative) int |
remove, fclose (EOF),
most POSIX functions (-1)
|
| Pointer result | NULL |
fopen, malloc, fgets,
strchr (not found)
|
| Special value in the normal range |
EOF, -1, a count that is too low
|
getchar, scanf (fewer items than
asked), printf (negative)
|
| Side channel | errno set to an error number |
fopen, strtol (out of range),
malloc on most systems
|
Reporting errors through return values
Special values in the result
The simplest convention reserves a value that a valid result can
never have. A meter reading in kWh is never negative, so
-1 can mean "unknown meter". It works as long as every
caller knows the rule and the reserved value really is impossible —
for a temperature in degrees Celsius, -1 would be a
perfectly normal value.
#include <stdio.h>
/* Returns the meter's reading in kWh, or -1 if the meter is unknown. */
static int last_reading(int meter)
{
switch (meter) {
case 30004: return 412;
case 30007: return 1500;
default: return -1; /* sentinel: not a valid reading */
}
}
int main(void)
{
int meters[] = {30004, 30099, 30007};
for (int i = 0; i < 3; i++) {
int kwh = last_reading(meters[i]);
if (kwh < 0) {
printf("M-%d: unknown meter, skipped\n", meters[i]);
continue;
}
printf("M-%d: %d kWh\n", meters[i], kwh);
}
return 0;
}
M-30004: 412 kWh M-30099: unknown meter, skipped M-30007: 1500 kWh
Error codes
An error code is a number a
function returns to say whether it succeeded and, if not, what went
wrong. The common C style is 0 for success and a
non-zero value for each kind of failure, often named with an
enum so that callers can tell the cases apart and print
a useful message. The real result then has to travel another way —
usually through a pointer the caller passes in.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
enum parse_status { PARSE_OK = 0, PARSE_EMPTY, PARSE_NOT_A_NUMBER, PARSE_NEGATIVE };
/* Reads a whole number of kWh from text. The result goes into *kwh;
the return value only says whether that worked. */
static enum parse_status parse_kwh(const char *text, long *kwh)
{
if (text[0] == '\0') {
return PARSE_EMPTY;
}
char *end;
long value = strtol(text, &end, 10);
if (*end != '\0') {
return PARSE_NOT_A_NUMBER;
}
if (value < 0) {
return PARSE_NEGATIVE;
}
*kwh = value; /* only written on success */
return PARSE_OK;
}
static const char *status_text(enum parse_status status)
{
switch (status) {
case PARSE_OK: return "ok";
case PARSE_EMPTY: return "empty field";
case PARSE_NOT_A_NUMBER: return "not a whole number";
case PARSE_NEGATIVE: return "negative reading";
}
return "unknown status";
}
int main(void)
{
const char *fields[] = {"350", "", "12x4", "-20"};
for (int i = 0; i < 4; i++) {
long kwh = 0;
enum parse_status status = parse_kwh(fields[i], &kwh);
if (status == PARSE_OK) {
printf("\"%s\" -> %ld kWh\n", fields[i], kwh);
} else {
printf("\"%s\" -> error %d (%s)\n", fields[i], status, status_text(status));
}
}
return 0;
}
"350" -> 350 kWh "" -> error 1 (empty field) "12x4" -> error 2 (not a whole number) "-20" -> error 3 (negative reading)
Zero for success looks backward at first (zero is "false" in an
if), but it leaves every non-zero value free for a
different error, and it matches the exit status convention of the
shell (see below).
Output parameters
parse_kwh above returns its status and delivers the
number through long *kwh. A pointer parameter that a
function writes its result into is an
output parameter; it is
also how a C function returns more than one value. How pointers make
this possible is explained in Pointers.
Two habits keep output parameters safe:
- Write the output only on success. The caller's variable then keeps its old (initialized) value after a failure, instead of half-parsed garbage.
-
Document whether the pointer may be
NULL. A function that dereferences aNULLoutput pointer crashes.
The compiler can insist on a check
Ignoring a status is the most common error-handling bug in C, and
SEI CERT C rule ERR33-C, "Detect and handle standard library
errors", exists for exactly this reason. C23 lets a function demand
attention with the [[nodiscard]] attribute; GCC and
Clang have long offered the same check as
__attribute__((warn_unused_result)). With
-Werror, a caller that throws the status away no longer
compiles. This example is compiled with Clang in C23 mode:
#include <stdio.h>
#include <stdlib.h>
/* C23: callers must not ignore the status. */
[[nodiscard]] static int parse_kwh(const char *text, long *kwh)
{
char *end;
*kwh = strtol(text, &end, 10);
return (end != text && *end == '\0') ? 0 : -1;
}
int main(void)
{
long kwh = 0;
parse_kwh("12x4", &kwh); /* status thrown away */
printf("%ld kWh\n", kwh);
return 0;
}
example.c:15:5: error: ignoring return value of function declared with 'nodiscard' attribute [-Werror,-Wunused-result]
NULL, EOF and the standard library
Before using a standard function, look up what it returns on failure; it differs per function, and some of the differences are traps.
| Function | On failure | Watch out for |
|---|---|---|
fopen |
NULL, errno set (POSIX) |
check before the first read or write |
malloc, calloc,
realloc
|
NULL |
p = realloc(p, n) loses the old block on
failure (see
Dynamic Memory)
|
fgets |
NULL at end of file or on error |
use feof/ferror to tell them apart
|
getchar, fgetc |
EOF (a negative int) |
store the result in an int, never a
char
|
scanf, sscanf |
fewer items than requested, or EOF |
compare with the exact number you expect |
fclose |
EOF |
buffered data is written here; a full disk shows up now |
strtol, strtoul |
no special value | check errno and the end pointer (below) |
atoi |
no way to tell | returns 0 for "abc"; avoid it for input |
errno: the error number
Many library functions say that they failed through their
return value and why through
errno, an integer declared in <errno.h> that a
failing function sets to an error number such as
ENOENT (no such file or directory). Since C11 each
thread has its own errno, so threads do not overwrite
each other's value. strerror from
<string.h> turns a number into a message:
#include <errno.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
int codes[] = {ENOENT, EACCES, ENOMEM, EINVAL};
for (int i = 0; i < 4; i++) {
printf("%2d %s\n", codes[i], strerror(codes[i]));
}
return 0;
}
2 No such file or directory 13 Permission denied 12 Cannot allocate memory 22 Invalid argument
The C standard itself defines only three values (EDOM,
EILSEQ and ERANGE); POSIX adds the rest.
The numbers and the exact wording come from the C library — the
output above is glibc's on Linux — so compare against the names
(errno == ENOENT), never against the numbers or the
text.
perror and saving errno
perror(s) writes
s, a colon, a space and the message for the current
errno to
standard error. It is the quickest way to report a failed system or library call.
But errno is only a global variable, and any later
library call — including perror and
printf themselves — may change it, even when that call
succeeds. On this machine, perror left a different
value behind:
#include <errno.h>
#include <stdio.h>
#include <string.h>
int main(void)
{
FILE *file = fopen("readings-2026-10.csv", "r");
if (file == NULL) {
int saved = errno;
printf("before perror: errno=%d\n", errno);
perror("readings-2026-10.csv");
printf("after perror: errno=%d\n", errno);
printf("saved copy: %d (%s)\n", saved, strerror(saved));
}
return 0;
}
before perror: errno=2 readings-2026-10.csv: No such file or directory after perror: errno=22 saved copy: 2 (No such file or directory)
The value 22 (EINVAL) is a leftover from glibc's work
inside perror, not a new error:
perror succeeded. The rule is simple — copy
errno into a local variable immediately after the
failing call, before anything else runs.
Rules for reading errno
-
Look at
errnoonly after the return value says the call failed. A successful call may leave any old value in it. -
No library function ever sets
errnoto 0. For a function whose return value cannot signal failure, such asstrtol, seterrno = 0yourself before the call and check it afterward. -
Check the documentation per function: the C standard does not
require
fopenormallocto seterrno; POSIX and glibc do.
strtol shows the full protocol: errno
reports a number out of range, and the end pointer reports text that
is not a number at all.
#include <errno.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* strtol's full error protocol: set errno to 0, call, then check
errno, the end pointer and the remaining text. */
static int to_long(const char *text, long *out)
{
char *end;
errno = 0;
long value = strtol(text, &end, 10);
if (errno == ERANGE) {
printf("%-22s out of range: %s\n", text, strerror(errno));
return -1;
}
if (end == text || *end != '\0') {
printf("%-22s not a whole number\n", text);
return -1;
}
*out = value;
return 0;
}
int main(void)
{
const char *inputs[] = {"3504000000", "99999999999999999999", "12x4", ""};
for (int i = 0; i < 4; i++) {
long value;
if (to_long(inputs[i], &value) == 0) {
printf("%-22s ok: %ld\n", inputs[i], value);
}
}
return 0;
}
3504000000 ok: 3504000000
99999999999999999999 out of range: Numerical result out of range
12x4 not a whole number
not a whole number
3,504,000,000 fits because long is 64 bits on this
machine; on systems where long is 32 bits (64-bit
Windows, many microcontrollers) the first line would be out of range
as well. Integer sizes are covered in
Variables, Types and Integers.
Validating input: BUG-1060 in C
Error handling starts with deciding what counts as an error. The
Kestrel Home pilot dongle from
Smart Meters and the P1 Port
is built by an outside manufacturer and sold under Kestrel's name;
its firmware is the manufacturer's C code. BUG-1060 (24 September
2026) was a parser that accepted exactly four CRC digits, while one
meter sent three: B9F. The tempting fix in C is to let
strtoul convert "whatever is there". It accepts far
more than hexadecimal digits:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
/* The CRC field after the '!' at the end of a telegram. */
const char *fields[] = {"F1F4", "B9F", " 0xB9F", "-1", "F1F4F", ""};
for (int i = 0; i < 6; i++) {
char *end;
unsigned long crc = strtoul(fields[i], &end, 16);
int width = printf("\"%s\"", fields[i]);
printf("%*s-> %lX (used %td characters)\n",
10 - width, "", crc, end - fields[i]);
}
return 0;
}
"F1F4" -> F1F4 (used 4 characters) "B9F" -> B9F (used 3 characters) " 0xB9F" -> B9F (used 6 characters) "-1" -> FFFFFFFFFFFFFFFF (used 2 characters) "F1F4F" -> F1F4F (used 5 characters) "" -> 0 (used 0 characters)
Leading spaces, a 0x prefix and a minus sign are all
accepted (the minus is applied by wrapping around, which turns
-1 into the largest unsigned long); five
digits are not a CRC-16 at all, and an empty field becomes a "valid"
0 unless the caller checks the end pointer. A parser for device data
should say exactly what it accepts and reject the rest with an error
code:
#include <stdint.h>
#include <stdio.h>
/* Value of one hexadecimal digit, or -1 if c is not one. */
static int hex_value(char c)
{
if (c >= '0' && c <= '9') return c - '0';
if (c >= 'A' && c <= 'F') return c - 'A' + 10;
if (c >= 'a' && c <= 'f') return c - 'a' + 10;
return -1;
}
/* Parses the CRC field of a P1 telegram: 1 to 4 hex digits, then
exactly "\r\n". Returns 0 and stores the CRC on success, -1 otherwise. */
static int parse_crc_field(const char *field, uint16_t *crc)
{
unsigned value = 0;
int digits = 0;
while (hex_value(field[digits]) >= 0) {
if (digits == 4) {
return -1; /* five or more digits */
}
value = value * 16 + (unsigned)hex_value(field[digits]);
digits++;
}
if (digits == 0 || field[digits] != '\r' || field[digits + 1] != '\n'
|| field[digits + 2] != '\0') {
return -1;
}
*crc = (uint16_t)value;
return 0;
}
int main(void)
{
const char *fields[] = {"F1F4\r\n", "B9F\r\n", " 0xB9F\r\n",
"-1\r\n", "F1F4F\r\n", "\r\n"};
for (int i = 0; i < 6; i++) {
uint16_t crc;
if (parse_crc_field(fields[i], &crc) == 0) {
printf("field %d: CRC %04X\n", i + 1, (unsigned)crc);
} else {
printf("field %d: rejected\n", i + 1);
}
}
return 0;
}
field 1: CRC F1F4 field 2: CRC 0B9F field 3: rejected field 4: rejected field 5: rejected field 6: rejected
One to four digits, then exactly "\r\n": the short CRC
from BUG-1060 passes, and everything that cannot be verified is
refused. Code like this is also the natural target for
fuzzing, because
it is the part of the device that reads untrusted input. How strings
and buffers work underneath is on
Strings and Buffer Overflows.
Exit status: telling the shell
A C program reports its overall result to whoever started it through
the return value of main (or the argument of
exit()). The operating system keeps the low 8 bits, and
the shell makes them available as the
exit status
in $?. <stdlib.h> defines two
portable values,
EXIT_SUCCESS and EXIT_FAILURE; on Linux they are 0 and 1. A failing program should write a
message to standard error and return a non-zero status:
#include <stdio.h>
#include <stdlib.h>
int main(void)
{
const char *path = "readings-2026-10.csv";
FILE *file = fopen(path, "r");
if (file == NULL) {
perror(path);
return EXIT_FAILURE;
}
/* ... read the readings ... */
fclose(file);
return EXIT_SUCCESS;
}
readings-2026-10.csv: No such file or directory
Built as a command-line tool, the same idea lets scripts react to the result. The convention many tools follow is 0 for success, 1 for a failure while doing the work and 2 for wrong usage:
cat > check_file.c <<'C'
#include <stdio.h>
#include <stdlib.h>
int main(int argc, char *argv[])
{
if (argc != 2) {
fprintf(stderr, "usage: %s FILE\n", argv[0]);
return 2; /* convention: 2 = wrong usage */
}
FILE *file = fopen(argv[1], "r");
if (file == NULL) {
perror(argv[1]);
return EXIT_FAILURE; /* 1 */
}
fclose(file);
printf("%s is readable\n", argv[1]);
return EXIT_SUCCESS; /* 0 */
}
C
gcc -std=c17 -Wall -Wextra -o check_file check_file.c
echo "M-30004,412" > readings.csv
./check_file readings.csv; echo "exit status: $?"
./check_file missing.csv; echo "exit status: $?"
./check_file; echo "exit status: $?"
./check_file readings.csv && echo "next step runs"
readings.csv is readable exit status: 0 missing.csv: No such file or directory exit status: 1 usage: ./check_file FILE exit status: 2 readings.csv is readable next step runs
return n; in main and
exit(n) anywhere else both flush and close open
FILE streams and run functions registered with
atexit. abort() does neither: it raises
the SIGABRT
signal, and
the shell reports exit status 134 (128 + 6). A segmentation fault
gives 139 (128 + 11). Reading those numbers is part of
Debugging C.
Cleaning up: the goto cleanup pattern
A function that opens a file, allocates a buffer and then parses data has three places where it can fail, and each failure must release exactly what was acquired so far. Without exceptions or destructors, the usual answer in C is goto cleanup: on an error, jump forward to a chain of labels at the end of the function that release the resources in reverse order. The success path falls through the same labels, so there is one exit and every resource is released in one place.
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
/* Adds up the kWh column of a readings file ("meter,kwh" per line).
Returns 0 on success, -1 on any error; every exit path frees and
closes exactly what was acquired. */
static int sum_readings(const char *path, long *total)
{
int result = -1; /* pessimistic default */
long sum = 0;
int line_no = 0;
char *line = NULL;
FILE *file = fopen(path, "r");
if (file == NULL) {
perror(path);
goto out; /* nothing acquired yet */
}
line = malloc(64);
if (line == NULL) {
fprintf(stderr, "out of memory\n");
goto close_file;
}
while (fgets(line, 64, file) != NULL) {
line_no++;
long kwh;
if (sscanf(line, "%*[^,],%ld", &kwh) != 1) {
fprintf(stderr, "%s:%d: bad line\n", path, line_no);
goto free_line;
}
sum += kwh;
}
if (ferror(file)) {
perror(path);
goto free_line;
}
*total = sum;
result = 0; /* success */
free_line:
free(line);
close_file:
fclose(file);
out:
return result;
}
static void write_file(const char *path, const char *text)
{
FILE *file = fopen(path, "w");
if (file != NULL) {
fputs(text, file);
fclose(file);
}
}
int main(void)
{
write_file("good.csv", "M-30004,412\nM-30007,1500\n");
write_file("bad.csv", "M-30004,412\nM-30007,n/a\n");
const char *paths[] = {"good.csv", "bad.csv", "missing.csv"};
for (int i = 0; i < 3; i++) {
long total;
if (sum_readings(paths[i], &total) == 0) {
printf("%s: total %ld kWh\n", paths[i], total);
} else {
printf("%s: skipped\n", paths[i]);
}
}
return 0;
}
good.csv: total 1912 kWh bad.csv:2: bad line bad.csv: skipped missing.csv: No such file or directory missing.csv: skipped
A few details make the pattern work. result starts as
the failure value, so only the line that is reached after everything
succeeded changes it. Variables used after the labels are declared
at the top, before the first goto. And the jumps only
go forward, to the end of the same function — this is the one use of
goto that most C style guides accept (see
Control Flow and Loops). A
variant with a single label works too, because
free(NULL) does nothing: initialize every pointer to
NULL and test before fclose.
assert: checks for programming mistakes
assert(expression), from <assert.h>, checks a condition that must
always be true if the program is correct. When it is false, the
program prints the file, line, function and expression to standard
error and calls abort(). It documents an assumption and
stops the program at the first sign that the assumption broke, close
to the cause instead of far downstream.
#include <assert.h>
#include <stdio.h>
/* Cost in cents at the fixed tariff of 27 cents per kWh. */
static long cost_cents(int kwh)
{
assert(kwh >= 0); /* callers validate input first */
return kwh * 27L;
}
int main(void)
{
printf("350 kWh: %ld cents\n", cost_cents(350));
printf("-20 kWh: %ld cents\n", cost_cents(-20));
return 0;
}
350 kWh: 9450 cents example: example.c:7: cost_cents: Assertion `kwh >= 0' failed. Aborted
Compiled with -DNDEBUG, which many release builds set,
every assert expands to nothing: the check and any side
effects inside it disappear. Two rules follow:
-
Use
assertfor bugs (a function called with an argument its caller should have checked), never for input validation or for errors that can happen in a correct program, such as a missing file. Those need real error handling. -
Never put work inside it:
assert(fclose(file) == 0)stops closing the file in a release build.
Checks at compile time
Some assumptions can be checked before the program ever runs.
_Static_assert (C11, also available as the macro
static_assert from <assert.h>; a
keyword in C23) evaluates a constant expression at compile time and
stops the build with your message when it is false. It suits facts
about the platform and data layout, such as the size of a structure
that is sent over a wire:
#include <stdint.h>
#include <stdio.h>
struct reading {
uint32_t meter;
uint16_t watts;
uint16_t flags;
};
/* Checked by the compiler, costs nothing at run time. */
_Static_assert(sizeof(struct reading) == 8, "reading must stay 8 bytes");
_Static_assert(sizeof(long) == 4, "this code assumes a 32-bit long");
int main(void)
{
printf("%zu\n", sizeof(struct reading));
return 0;
}
example.c:12:1: error: static assertion failed: "this code assumes a 32-bit long"
The first assertion holds; the second fails on this machine, where
long is 64 bits, so the assumption is caught at build
time instead of as a wrong result in production.
setjmp and longjmp: non-local jumps
<setjmp.h> offers the closest thing C has to
throwing an exception. setjmp(env) saves the current
execution state and returns 0. A later longjmp(env, 1),
from any function called in between, jumps back to that point, and
setjmp appears to return a second time, now with 1.
#include <setjmp.h>
#include <stdio.h>
static jmp_buf on_error;
static int parse_digit(char c)
{
if (c < '0' || c > '9') {
longjmp(on_error, 1); /* back to setjmp, which now returns 1 */
}
return c - '0';
}
static int parse_number(const char *text)
{
int value = 0;
for (const char *p = text; *p != '\0'; p++) {
value = value * 10 + parse_digit(*p);
}
return value;
}
int main(void)
{
const char *inputs[] = {"412", "4x2"};
for (int i = 0; i < 2; i++) {
if (setjmp(on_error) == 0) {
printf("%s -> %d\n", inputs[i], parse_number(inputs[i]));
} else {
printf("%s -> not a number\n", inputs[i]);
}
}
return 0;
}
412 -> 412 4x2 -> not a number
It works, but nothing between the longjmp and the
setjmp gets a chance to clean up: memory allocated and
files opened in the skipped functions leak. The function that called
setjmp must still be running when
longjmp is called, and its local variables that changed
after setjmp have indeterminate values afterward unless
they are declared volatile. Interpreters and some
parsers use it carefully; in ordinary code, return values are
clearer.
Good practices
-
Check every return value that can signal failure, especially
fopen,malloc,scanf,fcloseandstrtol. Where you deliberately ignore one, write(void)before the call so readers know it was a choice (this silences[[nodiscard]], but GCC still warns for__attribute__((warn_unused_result))). - Pick one convention per code base — for example "0 or a negative error code" — and document it next to each function.
- Report errors where you can add context (which file, which line of input), and only once: either log and handle, or return the code to the caller, not both at every level.
- Write diagnostics to standard error and finish with a non-zero exit status, so scripts and schedulers notice the failure.
-
Release resources on every path;
gotocleanup keeps that in one place. -
Use
assertand_Static_assertfor assumptions, not for input — and treat input from files, networks and devices as hostile until it is validated.
The memory errors that no return value reports — reading past a buffer, using freed memory — are the subject of Undefined Behavior and Safer C.