C Memory Leaks and Dangling Pointers

Memory leaks and dangling pointers are two of the most important memory bugs in C. A memory leak happens when a program allocates memory and then loses the ability or responsibility to free it. A dangling pointer happens when a pointer still stores an address after the object at that address is no longer alive.

Overview: How Memory Leaks and Dangling Pointers Work

C gives you direct control over dynamic memory with malloc, calloc, realloc, and free. That control is powerful because an object can live beyond the function that created it, but it also means C will not automatically track ownership for you. If a successful allocation is never passed to free, the block remains reserved until the process exits. In a short command-line program this may disappear when the program ends, but in servers, tools, games, editors, and embedded systems, repeated leaks can grow until performance drops or allocation fails.

A dangling pointer is the opposite lifetime error. The pointer variable still exists, but the object it points to does not. This can happen after free(ptr), after returning the address of a local variable, or after using a pointer into an object that has been destroyed or reallocated. Dereferencing a dangling pointer is undefined behavior: the program may print old data, crash, corrupt another object, or appear to work in testing.

The key idea is ownership. At any moment, each dynamically allocated block should have a clear owner: the part of the program responsible for freeing it exactly once. Other pointers may temporarily observe the same block, but they must not free it unless ownership is transferred. Good C code is often less about clever pointer tricks and more about making lifetimes obvious.

Bug What went wrong Typical result
Memory leak No reachable owner remains, or cleanup was skipped Memory use grows over time
Dangling pointer A pointer is used after the target lifetime ended Undefined behavior
Double free The same allocation is freed more than once Allocator corruption or abort
Lost realloc result The original pointer is overwritten before failure is checked Leak of the original block

Syntax

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *value = malloc(sizeof *value);
    if (value == NULL) {
        printf("allocation failed\n");
        return 1;
    }

    *value = 25;
    printf("value = %d\n", *value);

    free(value);
    value = NULL;
    return 0;
}

Output:

value = 25
  • malloc(sizeof *value) allocates enough storage for one int.
  • value == NULL checks whether allocation failed before dereferencing.
  • free(value) ends ownership of the allocated block.
  • value = NULL does not free anything by itself; it only clears this pointer variable so accidental later checks are safer.

Examples

Pairing every allocation with cleanup

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    size_t count = 4;
    int *scores = malloc(count * sizeof *scores);

    if (scores == NULL) {
        printf("could not allocate scores\n");
        return 1;
    }

    for (size_t i = 0; i < count; i++) {
        scores[i] = (int)((i + 1) * 10);
    }

    printf("scores:");
    for (size_t i = 0; i < count; i++) {
        printf(" %d", scores[i]);
    }
    printf("\n");

    free(scores);
    scores = NULL;
    printf("scores released\n");
    return 0;
}

Output:

scores: 10 20 30 40
scores released

This is the simplest healthy pattern: allocate, check, use, and release. The pointer variable scores is local, but the allocated array is not automatically released when the variable goes out of scope. The explicit free is what prevents the leak.

A cleanup path for multiple allocations

#include <stdio.h>
#include <stdlib.h>
#include <string.h>

typedef struct {
    char *name;
    int *scores;
    size_t count;
} Report;

int init_report(Report *report, const char *name, size_t count) {
    report->name = malloc(strlen(name) + 1);
    report->scores = malloc(count * sizeof *report->scores);
    report->count = 0;

    if (report->name == NULL || report->scores == NULL) {
        free(report->name);
        free(report->scores);
        report->name = NULL;
        report->scores = NULL;
        return 0;
    }

    strcpy(report->name, name);
    report->count = count;
    for (size_t i = 0; i < count; i++) {
        report->scores[i] = (int)(80 + i * 3);
    }
    return 1;
}

void destroy_report(Report *report) {
    free(report->scores);
    free(report->name);
    report->scores = NULL;
    report->name = NULL;
    report->count = 0;
}

int main(void) {
    Report report;

    if (!init_report(&report, "Ada", 3)) {
        printf("report allocation failed\n");
        return 1;
    }

    printf("%s:", report.name);
    for (size_t i = 0; i < report.count; i++) {
        printf(" %d", report.scores[i]);
    }
    printf("\n");

    destroy_report(&report);
    printf("report destroyed\n");
    return 0;
}

Output:

Ada: 80 83 86
report destroyed

Real objects often own more than one allocation. This example gives the structure a matching initialization and destruction function. If either allocation fails, the initializer frees whatever succeeded before returning failure. The destroy function can then be called once on a fully initialized object.

Avoiding a dangling pointer after free

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    int *level = malloc(sizeof *level);
    if (level == NULL) {
        printf("allocation failed\n");
        return 1;
    }

    *level = 7;
    printf("level before free: %d\n", *level);

    free(level);
    level = NULL;

    if (level == NULL) {
        printf("level pointer is cleared\n");
    }

    return 0;
}

Output:

level before free: 7
level pointer is cleared

The program reads the value only while the allocation is alive. After free, the pointer is cleared and no dereference is attempted. Setting one pointer to NULL does not clear other copies of the same address, so it is useful but not a complete ownership system.

Using realloc without losing the old block

#include <stdio.h>
#include <stdlib.h>

int main(void) {
    size_t count = 3;
    int *values = malloc(count * sizeof *values);
    if (values == NULL) {
        printf("initial allocation failed\n");
        return 1;
    }

    for (size_t i = 0; i < count; i++) {
        values[i] = (int)(i + 1);
    }

    size_t new_count = 5;
    int *larger = realloc(values, new_count * sizeof *values);
    if (larger == NULL) {
        free(values);
        printf("resize failed\n");
        return 1;
    }

    values = larger;
    for (size_t i = count; i < new_count; i++) {
        values[i] = (int)(i + 1);
    }
    count = new_count;

    printf("values:");
    for (size_t i = 0; i < count; i++) {
        printf(" %d", values[i]);
    }
    printf("\n");

    free(values);
    return 0;
}

Output:

values: 1 2 3 4 5

realloc can return a different address. If it fails, it returns NULL and leaves the original allocation untouched. Storing the result in larger first keeps the old pointer available for cleanup.

How It Works Step by Step

  1. The program requests a block with an allocation function. If successful, the returned pointer becomes the only way to identify that block.
  2. The program records ownership in a variable, structure member, or caller contract. For example, a function might return a newly allocated string and document that the caller must free it.
  3. Other code may borrow the pointer to read or modify the object, but borrowing does not imply responsibility to call free.
  4. When the owner is finished, it calls free exactly once. The allocation lifetime ends immediately.
  5. After that point, every pointer value that still contains the old address is dangling. Comparing it to NULL is allowed, assigning over it is allowed, but dereferencing it or freeing it again is not.

The allocator may reuse freed memory for a later allocation. That is why use-after-free bugs are dangerous: a stale pointer may appear to work until it silently writes into a completely different object.

Common Mistakes

Overwriting the only pointer

If you assign a new address to the only pointer that owned a block before freeing the old block, the old block is leaked. Free first, or keep separate variables for separate allocations. With realloc, always use a temporary pointer until success is known.

Returning a pointer to a local variable

A local variable with automatic storage stops existing when its block ends. Returning &local gives the caller a dangling pointer. Return the value itself, let the caller pass in storage, or allocate dynamic storage and clearly transfer ownership.

Freeing through two aliases

Two pointer variables can store the same address, but there is still only one allocation. Freeing through one alias ends the block. Freeing through the other alias afterward is a double free, not a second cleanup. Choose one owner and treat other pointers as non-owning borrowed references.

Best Practices

  • Write down ownership in function names, comments, or API contracts: who allocates, who frees, and when.
  • Pair every successful allocation with a cleanup path, including early returns after errors.
  • Prefer one exit cleanup section in functions that allocate several resources.
  • Set long-lived owning pointers to NULL after free, but remember this does not clear aliases.
  • Never dereference a pointer after the target object has been freed or gone out of scope.
  • Do not call free on stack addresses, string literals, or pointers not returned by allocation functions.
  • Use a temporary variable for realloc results.
  • Use tools such as AddressSanitizer or Valgrind during testing to detect leaks and use-after-free bugs.

Practice Exercises

  1. Write a function int *copy_array(const int *src, size_t count) that allocates a new array, copies the values, and returns NULL if allocation fails. Document who must free the result.
  2. Create a Book structure with a heap-allocated title. Write matching init_book and destroy_book functions.
  3. Take a program that has two early return statements after allocation and rewrite it so every path releases owned memory exactly once.

Summary

  • A memory leak is allocated memory that is no longer freed by any reachable owner.
  • A dangling pointer stores an address whose object lifetime has ended.
  • free releases the allocation, not the pointer variable itself.
  • Each dynamic allocation needs one clear owner and exactly one successful cleanup.
  • Use temporary pointers with realloc so failure does not lose the original block.
  • Safe C memory management is mostly disciplined lifetime and ownership management.