# The C mistakes language models actually make

> Source: https://learn-c.net/review/failure-modes/
> Part of Learn C, free to read.

C is the language where the review stakes are highest. Everywhere else, a generated bug produces a wrong answer, an exception, a test failure. Here it produces a heap overflow that works fine for six months and then does not.

The good news is that C has the best free tooling of any language for finding exactly these bugs, and almost nobody turns it on. **If you are writing C with an agent, the sanitizers are not optional.**

## Memory

### 1. Allocation results used unchecked

```c
char *buf = malloc(n);
memcpy(buf, src, n);        // NULL deref if malloc failed
```

The single most common generated C bug. It appears because most C in the training data is example code where the check was elided for brevity.

**Catch it with:** `-fanalyzer` (GCC) or `clang --analyze`. Both flag it reliably.

### 2. Off-by-one on the null terminator

```c
char dst[16];
strncpy(dst, src, sizeof(dst));    // no null terminator if src is >= 16
printf("%s", dst);                 // reads past the buffer
```

`strncpy` does not guarantee termination. Neither does `strncat`'s size argument mean what people think. Generated string handling gets this wrong constantly.

**Correct:** `snprintf(dst, sizeof dst, "%s", src)`, which always terminates. Or `strlcpy` where available.

### 3. `sprintf`, `strcpy`, `strcat`, `gets`

Still generated, because there is an enormous amount of pre-2000 C in the training data. There is no safe use of `gets`; the others need a bounded variant.

**Catch it with:** `-D_FORTIFY_SOURCE=3 -O2` turns many of these into runtime aborts, and `-Wformat-security` catches the format-string cases at compile time.

### 4. `sizeof` on a pointer

```c
void process(int *arr) {
    int n = sizeof(arr) / sizeof(arr[0]);   // 1 on 64-bit, or 2. never the length.
}
```

Arrays decay to pointers at function boundaries. The idiom is correct in the caller and wrong in the callee, and generated code copies it across the boundary.

**Catch it with:** `-Wsizeof-pointer-div` (on by default in recent GCC/Clang).

### 5. Use after free, double free

Most often via an error path that frees and then falls through to a cleanup label that frees again.

**Catch it with:** AddressSanitizer. It finds these with a precise report including both stack traces. This is the single highest-value flag on this page.

### 6. Returning a pointer to a local

```c
char *greet(void) {
    char buf[64];
    snprintf(buf, sizeof buf, "hello");
    return buf;                 // dangling
}
```

**Catch it with:** `-Wreturn-local-addr` (default in GCC), and ASan at runtime.

### 7. `realloc` losing the original pointer

```c
p = realloc(p, n);              // if realloc fails, p is leaked and now NULL
```

**Correct:** assign to a temporary, check, then reassign.

## Integers and undefined behaviour

### 8. Signed integer overflow

Undefined behaviour, and the compiler is permitted to assume it does not happen — which is how overflow checks written *after* the arithmetic get optimised away entirely.

```c
if (a + b < a) { /* overflow */ }   // UB; the compiler may delete this
```

**Correct:** `__builtin_add_overflow(a, b, &r)`, or check before the operation.

**Catch it with:** UndefinedBehaviorSanitizer (`-fsanitize=undefined`).

### 9. Allocation size computed by multiplication

```c
void *p = malloc(count * sizeof(struct item));   // overflows -> tiny allocation
```

The classic path to a heap overflow. **Correct:** `calloc(count, sizeof(struct item))`, which checks the multiplication.

### 10. `int` for sizes and indices

Generated C uses `int` where `size_t` is correct, which breaks on large inputs and introduces signed/unsigned comparison bugs. **Catch it with:** `-Wconversion -Wsign-compare` (noisy, and worth it on new code).

### 11. Shifting by too much, or shifting a signed value

`1 << 31` on a 32-bit `int` is UB. Use `1u << 31`. UBSan catches it.

## Concurrency and resources

### 12. Non-atomic shared state

Generated pthread code frequently shares a counter or a flag with no mutex and no `_Atomic`. **Catch it with:** ThreadSanitizer (`-fsanitize=thread`).

### 13. Leaked file descriptors on error paths

The happy path closes; the four early returns do not. **Catch it with:** LeakSanitizer (bundled with ASan) plus a `goto cleanup` convention — which is one of the few places where `goto` is the right answer, and worth stating in `AGENTS.md`.

### 14. Ignoring return values

`read`, `write`, `close`, `fclose` all fail. Generated code checks `fopen` and then ignores everything after it. **Catch it with:** `-Wunused-result` and `__attribute__((warn_unused_result))` on your own APIs.

## The build that makes C survivable

This is the whole point of the page. Turn these on and most of the list above becomes a loud failure during development rather than a quiet one in production.

```makefile Makefile
CC      = clang
WARN    = -Wall -Wextra -Wpedantic -Wshadow -Wconversion -Wsign-compare \
          -Wformat=2 -Wformat-security -Wnull-dereference -Wstrict-prototypes
HARDEN  = -D_FORTIFY_SOURCE=3 -fstack-protector-strong
SAN     = -fsanitize=address,undefined -fno-omit-frame-pointer

debug: CFLAGS = -g -O1 $(WARN) $(HARDEN) $(SAN)
debug: LDFLAGS = -fsanitize=address,undefined
debug: $(TARGET)

.PHONY: check
check: debug
	./$(TARGET) $(TESTARGS)
	clang-tidy src/*.c -- $(WARN)
	cppcheck --enable=all --error-exitcode=1 src/
```

```bash
export ASAN_OPTIONS=detect_leaks=1:abort_on_error=1
export UBSAN_OPTIONS=print_stacktrace=1:halt_on_error=1
make check
```

Run the sanitized build as your *default* development build. The 2–3x slowdown is irrelevant while developing and it converts silent corruption into an immediate stack trace.

:::verdict If you do one thing
`-fsanitize=address,undefined` on your debug build. It finds items 1, 2, 3, 5, 6, 8, 9, 11 and 13 on this page, automatically, with precise reports. Nothing else on this page comes close to that return.
:::

## And fuzz the parsers

Any function that takes bytes from outside — a file format, a protocol, a config parser — should be fuzzed. It is about fifteen lines, and it is how you find the input nobody thought of.

```c fuzz_target.c
#include <stddef.h>
#include <stdint.h>

int LLVMFuzzerTestOneInput(const uint8_t *data, size_t size) {
    parse_packet(data, size);
    return 0;
}
```

```bash
clang -g -O1 -fsanitize=fuzzer,address,undefined fuzz_target.c src/parser.c -o fuzz
./fuzz -max_total_time=300 corpus/
```

Five minutes of fuzzing on a generated parser finds things no amount of reading does.

## Common questions

### Should I let an agent write C at all?

Yes, with the tooling above, and with more review attention than any other language. Generated C is often good — models know the idioms — but the cost of the residual errors is unbounded in a way it is not elsewhere. The sanitizers change the risk profile enough to make it a reasonable trade; without them it is not.

### Do sanitizers catch everything?

No. They are dynamic — they find bugs on the paths you actually execute. That is why the recommendation is sanitizers *plus* static analysis (`-fanalyzer`, `clang-tidy`, `cppcheck`) *plus* fuzzing for anything parsing untrusted input. The three have different blind spots.

### Is Rust the answer instead?

For new projects where you have the choice, memory safety by construction beats memory safety by tooling, and that argument gets stronger as generated code volume rises. But most C exists because of a constraint — an existing codebase, a platform, a kernel — and for that code, this page is what you have.

### Why does generated C use unsafe string functions?

Because decades of C in the training data uses them. `strcpy` was normal for a very long time. This is a good illustration of a general point: a model reproduces the distribution of its training data, and for C that distribution is heavily weighted towards code written before the current safety consensus.
