C's realloc Trap: The Pointer You Just Leaked

2026-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 Bug

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.

The Fix

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.

Key Takeaway: Never assign realloc's return value directly back into the only pointer you have — on failure you'll overwrite a live, leaked block with NULL.

All newsletters