realloc Trap: The Pointer You Just Leaked2026-05-10
This function is supposed to grow a dynamic buffer by additional bytes and zero-fill the new region. It compiles cleanly, passes every test on a machine with plenty of RAM, and ships to production. Months later, a long-running daemon starts ballooning in memory under load.
typedef struct {
char *data;
size_t size;
} Buffer;
int buffer_grow(Buffer *b, size_t additional) {
size_t new_size = b->size + additional;
b->data = realloc(b->data, new_size);
if (b->data == NULL) {
return -1; // caller will clean up... right?
}
memset(b->data + b->size, 0, additional);
b->size = new_size;
return 0;
}
The line b->data = realloc(b->data, new_size); is one of the most-cited foot-guns in the C standard library, and for good reason.
When realloc succeeds, it may return the same pointer or a new one, having freed the old block on your behalf. When it fails, however, it returns NULL and does nothing — the original allocation is still alive and still owned by you. By assigning the return value directly back into b->data, you have just overwritten your only handle to that live block with NULL. The memory is now unreachable: leaked for the lifetime of the process.
Worse, the caller has no way to recover. They see buffer_grow return -1, perhaps call free(b->data) as cleanup, and free NULL — a no-op. The original block is gone forever. On a server that occasionally hits an allocation failure under memory pressure, the leak compounds every time the failure path runs.
There's a secondary issue lurking too: b->size + additional can overflow size_t, in which case realloc may succeed with a tiny allocation and the subsequent memset writes far past the end. But the headline bug is the leak.
Always realloc into a temporary, check it, and only then commit:
int buffer_grow(Buffer *b, size_t additional) {
if (additional > SIZE_MAX - b->size) {
return -1; // overflow guard
}
size_t new_size = b->size + additional;
char *tmp = realloc(b->data, new_size);
if (tmp == NULL) {
return -1; // b->data still valid
}
b->data = tmp;
memset(b->data + b->size, 0, additional);
b->size = new_size;
return 0;
}
Now on failure, b->data still points to the original (intact) buffer. The caller can keep using it, retry later, or free it on their own terms. The contract is honored: on failure, nothing changes.
This pattern — "don't clobber the only pointer to a live resource until you know the new one is good" — generalizes far beyond realloc. It's the same reason you copy-then-rename when writing files atomically, and the same reason transactional code commits last. Every time you see x = operation(x) where operation can fail without cleaning up its input, you're looking at a potential leak.
Static analyzers like Clang's scan-build and Coverity flag the direct-assignment pattern, and most C style guides explicitly forbid it. If your codebase is older than the analyzer that found it, expect to find dozens.
realloc's return value directly back into the only pointer you have — on failure you'll overwrite a live, leaked block with NULL.
