Table of Contents
KEY TAKEAWAYS
- Always check return codes — unchecked errors are the #1 source of C bugs
- Use the goto cleanup pattern for functions that acquire multiple resources
- Use error callbacks to decouple error reporting from error handling
- Choose the pattern that fits your context — simple for small functions, richer for complex systems
- Be consistent within a project — pick one or two patterns and use them everywhere
Why Error Handling in C Is Hard
C has no exceptions, no try/catch, and no automatic cleanup. When something fails, you must detect it, report it, and clean up resources manually. Getting this right is one of the most important skills in C programming — especially in embedded systems where unhandled errors can cause hardware damage or safety hazards.This post covers the most common error handling patterns in C, with their trade-offs.Pattern 1: Return Codes
The simplest and most common pattern. Functions return a status code indicating success or the type of failure.typedef enum {
STATUS_OK = 0,
STATUS_ERR_NULL,
STATUS_ERR_RANGE,
STATUS_ERR_TIMEOUT,
STATUS_ERR_HARDWARE,
} Status;
Status sensor_init(Sensor *s, int channel) {
if (s == NULL) return STATUS_ERR_NULL;
if (channel 15) return STATUS_ERR_RANGE;
s->channel = channel;
if (!adc_configure(channel)) {
return STATUS_ERR_HARDWARE;
}
s->initialized = true;
return STATUS_OK;
}
// Caller checks the return code
Status st = sensor_init(&my_sensor, 3);
if (st != STATUS_OK) {
log_error("Sensor init failed: %d", st);
return st; // Propagate the error
}Pros: Simple, no overhead, widely understood.
Cons: Easy to ignore the return value. Output values must go through pointers.Pattern 2: Output Parameters + Return Code
When a function needs to return both a result and a status, use the return value for the status and an output pointer for the data.Status sensor_read(Sensor *s, float *out_value) {
if (s == NULL || out_value == NULL) return STATUS_ERR_NULL;
if (!s->initialized) return STATUS_ERR_HARDWARE;
int raw = adc_read(s->channel);
if (raw < 0) return STATUS_ERR_HARDWARE;
*out_value = (float)raw * 0.01f;
return STATUS_OK;
}
// Usage
float temperature;
if (sensor_read(&temp_sensor, &temperature) == STATUS_OK) {
display_temperature(temperature);
}Pattern 3: The Goto Cleanup Pattern
When a function acquires multiple resources (memory, file handles, hardware locks), cleanup on error becomes complex. Thegoto cleanup pattern is the standard C solution — and it is considered good practice, not bad style.Status process_file(const char *path) {
Status result = STATUS_OK;
FILE *f = NULL;
uint8_t *buffer = NULL;
f = fopen(path, "rb");
if (f == NULL) {
result = STATUS_ERR_HARDWARE;
goto cleanup;
}
buffer = malloc(1024);
if (buffer == NULL) {
result = STATUS_ERR_NULL;
goto cleanup;
}
size_t n = fread(buffer, 1, 1024, f);
if (n == 0) {
result = STATUS_ERR_HARDWARE;
goto cleanup;
}
// Process data...
result = parse_data(buffer, n);
cleanup:
free(buffer); // free(NULL) is safe
if (f) fclose(f);
return result;
}Why goto? Without it, you end up with deeply nested if-else blocks or duplicated cleanup code. The goto cleanup pattern is used extensively in the Linux kernel and most professional C codebases.Pattern 4: Error Callback
For libraries and reusable modules, let the caller decide how to handle errors by registering a callback.typedef void (*ErrorHandler)(int code, const char *msg);
static ErrorHandler g_error_handler = NULL;
void set_error_handler(ErrorHandler handler) {
g_error_handler = handler;
}
static void report_error(int code, const char *msg) {
if (g_error_handler) {
g_error_handler(code, msg);
}
}
// Inside library code
void comm_send(const uint8_t *data, int len) {
if (uart_send(data, len) < 0) {
report_error(ERR_COMM, "UART send failed");
}
}
// Application registers its handler
void my_error_handler(int code, const char *msg) {
printf("[ERR %d] %s\n", code, msg);
led_set(LED_RED, ON);
}
set_error_handler(my_error_handler);Pattern 5: Error Struct (Rich Error Info)
typedef struct {
int code;
char message[64];
const char *file;
int line;
} Error;
#define MAKE_ERROR(err, c, msg) do {
(err)->code = (c);
strncpy((err)->message, (msg), sizeof((err)->message) - 1);
(err)->file = __FILE__;
(err)->line = __LINE__;
} while(0)
Status sensor_init(Sensor *s, int ch, Error *err) {
if (s == NULL) {
MAKE_ERROR(err, ERR_NULL, "Sensor pointer is NULL");
return STATUS_ERR_NULL;
}
// ...
return STATUS_OK;
}This gives you detailed error context — which file, which line, what message. Very useful for debugging embedded systems where you cannot always run a debugger.Comparison
| Pattern | Best For | Overhead |
|---|---|---|
| Return codes | Simple functions | Minimal |
| Output params + return | Functions that produce a value | Minimal |
| Goto cleanup | Functions with multiple resources | None |
| Error callback | Libraries, reusable modules | Low |
| Error struct | Debug-rich systems | Moderate |
Key Takeaways
- Always check return codes — unchecked errors are the #1 source of C bugs
- Use the goto cleanup pattern for functions that acquire multiple resources
- Use error callbacks to decouple error reporting from error handling
- Choose the pattern that fits your context — simple for small functions, richer for complex systems
- Be consistent within a project — pick one or two patterns and use them everywhere

Vivek Bhageria — Lead Firmware R&D Engineer, 12+ years. Ex-Bosch (automotive powertrain), MusicTribe (real-time audio), medical devices. M.Tech BITS Pilani. I write at NerdyElectronics — practical, register-level embedded systems for engineers who want to understand what’s actually happening under the hood.






