Pointers & Memory · intermediate · ~10 min

Memory leaks

By the end of this lesson you will be able to: - Explain precisely what a memory leak is and why unreachable heap memory can never be recovered. - Recognize the classic leak patterns: early return on an error path, overwriting a pointer, losing an inner pointer, and forgetting to free in a loop. - Reason about ownership so every allocation has exactly one place responsible for freeing it. - Run Valgrind (`memcheck`) and AddressSanitizer (`-fsanitize=address` with the leak detector) to find leaks automatically and read their reports. - Refactor leaky code into a clean single-exit or goto-cleanup style that frees on every path. - Judge when a leak actually matters (long-running services) versus when it is cosmetic (short-lived tools).

Overview

A memory leak is heap memory your program asked for but can no longer reach or return. In the free and ownership lesson you learned that every malloc, calloc, or realloc hands you a block that you must give back with free. A leak is what happens when you never give it back — not because you chose to keep it, but because you lost the only pointer that could reach it.

Think of the heap as a coat-check room. Each malloc hands you a ticket (the pointer). free is you returning the ticket to reclaim the coat's space. If you lose the ticket, the coat stays hanging there forever: the room can never reuse that hook, and you can never get the coat back either. Losing the ticket is a leak.

In plain terms, a leak is not memory that is being used — it is memory that is stuck. Nothing points to it, so nothing can free it, but the allocator still considers it handed out. The operating system only reclaims it when your whole process exits.

This lesson builds directly on ownership: most leaks are ownership bugs in disguise. You allocated something, and then a code path forgot whose job it was to free it, or overwrote the pointer, or returned early. We will name each pattern, show tools that catch them, and give you habits that make leaks rare.

Why it matters

Leaks are the classic bug that hides until it hurts. A command-line tool that leaks a few kilobytes and exits after a second is harmless — the OS reclaims everything on exit. But most real software does not exit quickly.

A web server, a database, a game loop, a phone app, or an embedded controller runs for hours, days, or months, handling millions of requests. If each request leaks even a tiny block, the leaked memory grows without bound. Eventually the process exhausts RAM, the system starts thrashing swap, the allocator fails, and the service crashes or is killed by the OS. This is one of the most common causes of the dreaded "restart it every night to keep it alive" workaround.

Leaks also mask other bugs and make profiling noisy. And in constrained environments — a microcontroller with 64 KB of RAM — a single leak in a loop can bring the device down in seconds. Learning to prevent and detect leaks early is one of the highest-value habits a C programmer can build.

Core concepts

1. What exactly is leaked

Definition. A memory leak is a heap allocation that is still marked "in use" by the allocator but is no longer reachable through any pointer the program holds.

Plain explanation. When you call malloc, the allocator carves out a block and returns its address. Your program's only handle on that block is the pointer variable holding that address. If that variable is overwritten, goes out of scope, or is lost before you call free, the block becomes unreachable. It is not corrupted and not freed — it is orphaned.

How it works internally. malloc tracks allocations in bookkeeping structures (free lists, size headers) separate from your program's variables. The allocator has no idea which of your variables point where. free is the only signal that a block may be returned. No pointer means no possible free, which means the block stays reserved for the life of the process.

Heap after p = malloc(16):

  stack: p ──────────────►  [ 16-byte block ]   (reachable, freeable)

After p = malloc(16) AGAIN without freeing the first:

  stack: p ──────────────►  [ new 16-byte block ]
                            [ old 16-byte block ]  ◄── LEAKED
                            (no pointer reaches it, free() impossible)

When it matters / when not. It matters enormously in long-running processes and tight loops. It matters little for a program that allocates once and exits immediately — though relying on that is a fragile habit. Do not train yourself to "leak because the OS cleans up": that reasoning stops being true the moment the code is reused inside a server or library.

Common pitfall. Believing a leak is a crash you will see immediately. Leaks are silent. The program keeps working — memory usage just creeps upward until, much later and far from the real bug, it falls over.

Knowledge check: In your own words, why can the program never free a block once every pointer to it is lost?

2. Ownership: the mental model that prevents leaks

Definition. Ownership is the rule that for every allocation, exactly one part of the code is responsible for freeing it, exactly once.

Plain explanation. If you cannot answer "who frees this, and when?" the moment you write a malloc, you have a latent leak (or a double-free). The function that allocates does not always free — sometimes it returns the pointer and hands ownership to the caller. What matters is that ownership is documented and singular.

How it works internally. There is no runtime enforcement in C — ownership lives in your head and your comments. Common conventions: a function named create_* allocates and the caller owns the result; a function named free_*/destroy_* takes ownership and releases it. Pairing them makes the responsibility obvious.

When to use / not. Always establish ownership for heap data. For stack or static data there is no ownership question — you never free those.

Common pitfall. Two code paths each thinking the other one frees a block — so neither does (leak), or both do (double-free). Write down the contract.

Situation Who owns / frees Leak risk
p = malloc(); ... free(p); in one function that function Low if all paths free
char *s = make_string(); returned to caller the caller Caller forgets → leak
Struct holds a pointer field the struct's free_* Free struct but not field → leak
Pointer stored in two places must be exactly one owner Both free → double-free

Knowledge check: A function char *dup_upper(const char *s) returns a newly allocated uppercased copy. Who is responsible for freeing the returned string?

3. The four classic leak patterns

Definition. Nearly all C leaks fall into a few shapes; learning them lets you spot leaks by eye.

A. Early return on an error path. You allocate, then hit an error and return before reaching the free.

B. Overwriting the only pointer. p = malloc(...) when p already points to a live block — the old block becomes unreachable. Common with realloc misuse and with reusing a variable in a loop.

C. Losing an inner pointer. You free a struct but not the heap-allocated fields inside it; freeing the outer block does not free what its members point to.

D. Forgetting to free inside a loop. Allocating each iteration and only remembering to free the last one (or none).

Pattern B (overwrite) — leak in a loop:

  iteration 1:  buf ──► [block A]
  iteration 2:  buf ──► [block B]     [block A] leaked
  iteration 3:  buf ──► [block C]     [block A][block B] leaked
  ...
  Only block C is freeable at the end.

When it matters. Every allocation in code with multiple exit paths, loops, or nested ownership needs a deliberate free plan.

Common pitfall (Pattern C, nested): freeing node but not node->name, or freeing an array of pointers but not the strings they point to.

Knowledge check (find-the-bug): What leaks here, and how many bytes are lost after 100 iterations?

for (int i = 0; i < 100; i++) {
    char *line = malloc(64);
    process(line);
}

4. Tools: Valgrind and AddressSanitizer

Definition. Automated leak detectors run your program and report allocations that were never freed, with the source location where each was allocated.

Plain explanation. You do not hunt leaks by hand. Valgrind's memcheck runs your unmodified binary in a simulated CPU and tracks every malloc/free. AddressSanitizer (ASan) is compiled into your binary with -fsanitize=address; its LeakSanitizer component reports leaks at exit.

Valgrind (memcheck) AddressSanitizer
Setup none; run valgrind ./prog recompile with -fsanitize=address -g
Speed ~10–30x slower ~2x slower
Finds leaks yes, at exit yes (LeakSanitizer, default on Linux)
Also finds uninitialized reads, invalid access buffer overflows, use-after-free
Best for quick check, no recompile CI, fast dev loop

When to use / not. Use ASan during development and in CI for speed; use Valgrind when you cannot recompile or want its uninitialized-value checks. Do not ship a production binary built with either — they are diagnostic tools.

Common pitfall. Forgetting -g so reports show addresses instead of file:line. Always compile with debug info when hunting leaks.

Knowledge check: Which flag turns on AddressSanitizer, and why should you also pass -g?

Syntax notes

There is no special "anti-leak" syntax — the discipline is in how you structure allocation and cleanup. Two idioms matter most.

Single-exit with goto cleanup (the standard C pattern for multi-step allocation):

int do_work(void) {
    char *a = NULL, *b = NULL;
    int rc = -1;                  // assume failure

    a = malloc(100);
    if (!a) goto cleanup;         // jump to one cleanup point

    b = malloc(200);
    if (!b) goto cleanup;         // a is still freed below

    /* ... use a and b ... */
    rc = 0;                       // success

cleanup:
    free(b);                      // free(NULL) is safe
    free(a);
    return rc;
}

Free-then-null to reason about ownership (helps avoid overwrite leaks and later use-after-free):

free(p);
p = NULL;   // p no longer owns anything; safe to reassign or re-free

Key facts: free(NULL) is always safe and does nothing, which is what makes the goto cleanup idiom clean. realloc(p, n) may move the block; assign to a temporary first so you do not lose p if it returns NULL.

Lesson

What is a memory leak?

A memory leak happens when you allocate memory on the heap (with malloc, calloc, or realloc) but lose every pointer to it before calling free.

Once no pointer refers to that memory, you can no longer free it. The block stays reserved until the program exits, but your code can never reach it again.

Why does this matter? A short program may get away with it. But a long-running service slowly accumulates leaked blocks. Over time it exhausts available RAM and crashes.

How to find leaks

You do not have to hunt for leaks by hand. Two tools do it for you:

  • Valgrind (memcheck) — runs your program and reports leaked allocations.
  • AddressSanitizer — built into the compiler; enable it with -fsanitize=address.

Both tools tell you exactly which allocation was never freed and where in your code it was created.

Make leak checking a habit early. It is far easier to stay leak-free than to fix leaks later.

Code examples

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

/* Owns nothing on entry; returns a heap copy the CALLER must free.
   Returns NULL on allocation failure. */
static char *dup_upper(const char *s) {
    size_t n = strlen(s);
    char *out = malloc(n + 1);        /* +1 for the terminating '\0' */
    if (!out) return NULL;            /* propagate failure; nothing leaked */
    for (size_t i = 0; i < n; i++)
        out[i] = (char)toupper((unsigned char)s[i]);
    out[n] = '\0';
    return out;                       /* ownership transfers to caller */
}

int main(void) {
    const char *words[] = { "leak", "free", "heap" };
    size_t count = sizeof words / sizeof words[0];

    /* Allocate an array of owned strings. */
    char **upper = malloc(count * sizeof *upper);
    if (!upper) {
        fprintf(stderr, "out of memory\n");
        return EXIT_FAILURE;
    }

    for (size_t i = 0; i < count; i++) {
        upper[i] = dup_upper(words[i]);
        if (!upper[i]) {
            /* Error path: free everything allocated SO FAR, then bail. */
            for (size_t j = 0; j < i; j++)
                free(upper[j]);
            free(upper);
            fprintf(stderr, "out of memory\n");
            return EXIT_FAILURE;
        }
    }

    for (size_t i = 0; i < count; i++)
        printf("%s\n", upper[i]);

    /* Clean up: free each inner string, THEN the outer array. */
    for (size_t i = 0; i < count; i++)
        free(upper[i]);              /* free the inner allocations first */
    free(upper);                     /* then the array of pointers */

    return 0;
}

What it does. dup_upper allocates an uppercased copy of a string and hands ownership to the caller. main builds an array of such strings, prints them, and frees them in the correct order — inner strings first, then the outer array.

Expected output:

LEAK
FREE
HEAP

Why it is leak-free. Every malloc has a matching free on every path. The error path inside the loop frees only what was allocated so far (indices 0..i-1), then the array, then returns — no leak even when allocation fails midway. The cleanup at the end frees the nested upper[i] strings before the upper array itself, so no inner pointer is lost.

Edge cases. If dup_upper is called on an empty string, it still allocates 1 byte for the '\0' and must be freed. Freeing the outer array before the inner strings would leak every inner string (Pattern C) — order matters.

How to check it yourself:

gcc -g -fsanitize=address -o demo demo.c && ./demo
# or
gcc -g -o demo demo.c && valgrind --leak-check=full ./demo

A clean run reports zero leaked bytes ("All heap blocks were freed" / "no leaks are possible").

Line by line

  1. dup_upper computes n = strlen(s) and requests n + 1 bytes — the +1 is the room for the terminating '\0'. Forgetting it is a classic one-byte overflow, not a leak, but both are heap bugs.
  2. If malloc returns NULL, we return NULL immediately. Nothing was allocated yet in this function, so there is nothing to leak on that path.
  3. The loop copies each character uppercased; toupper is fed (unsigned char) to avoid undefined behavior on bytes with the high bit set. out[n] = '\0' terminates the string. return out transfers ownership to the caller — from here on, whoever received the pointer must free it.
  4. In main, malloc(count * sizeof *upper) allocates room for count pointers (an array of char *). This block and each string it will point to are all separate heap allocations.
  5. The loop fills upper[i] with owned strings. Trace of ownership as it runs:
Step upper array Owned strings live
before loop allocated 0
i = 0 holds "LEAK" 1
i = 1 holds "FREE" 2
i = 2 holds "HEAP" 3
  1. If any dup_upper returns NULL, the inner error loop frees indices 0..i-1 (the ones already created), then frees upper, then returns failure. This is the correct partial-cleanup pattern — free exactly what you own, no more, no less.
  2. After printing, the final loop frees each upper[i] (the three strings), then free(upper) releases the array of pointers. Doing these in this order guarantees no inner pointer is orphaned. When main returns, the heap is empty.

Common mistakes

Mistake 1 — Early return leaks the allocation.

// WRONG
int parse(const char *path) {
    char *buf = malloc(256);
    if (open_file(path) < 0)
        return -1;          // buf leaked: pointer dies with the function
    /* ... */
    free(buf);
    return 0;
}

Why wrong: the early return skips free(buf); the local pointer vanishes and the block is orphaned.

// CORRECT
int parse(const char *path) {
    char *buf = malloc(256);
    if (!buf) return -1;
    int rc = -1;
    if (open_file(path) < 0) goto cleanup;
    /* ... */
    rc = 0;
cleanup:
    free(buf);
    return rc;
}

Prevent it: give a function one cleanup label and goto it on every error.

Mistake 2 — Overwriting the pointer (loop leak).

// WRONG
char *buf;
for (int i = 0; i < n; i++) {
    buf = malloc(64);       // previous block leaked every iteration
    use(buf);
}
free(buf);                  // frees only the LAST block

Why wrong: each iteration overwrites buf, orphaning the prior block. n-1 blocks leak.

// CORRECT
for (int i = 0; i < n; i++) {
    char *buf = malloc(64);
    if (!buf) break;
    use(buf);
    free(buf);              // free before the pointer is reused
}

Prevent it: free inside the loop, or scope the pointer to the loop body.

Mistake 3 — realloc losing the original block.

// WRONG
p = realloc(p, new_size);   // if realloc returns NULL, old p is leaked

Why wrong: on failure realloc returns NULL but the original block is still valid and now unreachable.

// CORRECT
char *tmp = realloc(p, new_size);
if (!tmp) { /* handle error; p still valid */ }
else p = tmp;

Mistake 4 — Freeing the struct but not its fields (nested leak).

// WRONG
free(user);                 // user->name (heap) is leaked
// CORRECT
free(user->name);
free(user);                 // free inner members before the container

Recognise it: whenever a struct owns pointer members, write a free_user() that frees the members first, then the struct.

Debugging tips

Compiler / build errors.

  • Implicit declaration of malloc/free: you forgot #include <stdlib.h>.
  • ASan link errors: you passed -fsanitize=address at compile but not at link — pass it to both steps (or just build in one gcc command).

Runtime symptoms of leaks.

  • Memory usage that only ever grows (watch with top, Task Manager, or /proc/<pid>/status VmRSS).
  • A long-running program that gets slower, then is killed by the OOM killer.
  • Valgrind/ASan reporting "definitely lost" or "detected memory leaks" at exit.

Reading a Valgrind report. Run valgrind --leak-check=full --show-leak-kinds=all ./prog. Look for:

  • definitely lost — a real leak; the block is unreachable. Fix these first.
  • still reachable — not freed, but a pointer still exists at exit (often global caches); lower priority. Each entry shows the allocation stack trace — the top frame with your file:line is where the leaked block was born. Compile with -g or you get addresses instead.

Reading an ASan report. Build with -g -fsanitize=address, run normally. At exit it prints ERROR: LeakSanitizer: detected memory leaks followed by Direct leak of N byte(s) and the allocation stack. On some platforms set ASAN_OPTIONS=detect_leaks=1 to force leak detection.

Questions to ask when it leaks.

  1. For this malloc, which single place is supposed to free it? If you cannot name it, that is the bug.
  2. Does every return/break/goto/error path between the malloc and the free actually reach a free?
  3. Is the pointer overwritten (in a loop, or by realloc) before being freed?
  4. Does this struct/array own inner pointers I also need to free?

Bisecting a slow leak. Wrap suspect sections in a loop of thousands of iterations and watch RSS — a leak grows linearly; correct code stays flat.

Memory safety

Leaks are one corner of a family of heap bugs. Keep these distinct in your mind:

Bug What happens Detected by
Leak block never freed; memory grows Valgrind, LeakSanitizer
Double free free called twice on same block ASan, glibc abort
Use-after-free using a pointer after free ASan, Valgrind
Buffer overflow write past the block's end ASan, Valgrind

Undefined behavior to avoid while fixing leaks.

  • Do not free a pointer twice; set it to NULL after freeing so a stray second free(NULL) is harmless.
  • Do not free a pointer that did not come from malloc/calloc/realloc (e.g. a pointer into the middle of a block, or a stack/static address) — that is undefined behavior, not a leak.
  • After free(p), p is a dangling pointer; reading or writing through it is use-after-free. p = NULL neutralizes it.

Overflow in size math. malloc(count * size) can overflow if count is attacker-influenced, allocating a tiny block you then overrun. Prefer calloc(count, size), which checks the multiplication, or validate count first. This is a security concern, not just a correctness one.

Defensive habits. Initialize pointers to NULL at declaration so free(NULL) on an unused path is safe. Give each owning struct a matching free_* destructor. Run ASan or Valgrind before every commit that touches allocation. In a long-running service, a leak is a denial-of-service risk: an attacker who can trigger a leaking code path repeatedly can exhaust memory and take the service down — so leaks on request-handling paths deserve the same scrutiny as overflows.

Real-world uses

Concrete case. Long-running network servers (nginx, Redis, PostgreSQL) live or die by leak-freedom. A single byte leaked per request, multiplied by billions of requests, would crash the service — so these projects run Valgrind/ASan in CI and treat any leak as a release blocker. Embedded firmware (routers, medical devices, car ECUs) has even less headroom: a leak in a control loop can crash a device with no operating system to reclaim memory on "exit" because the program never exits.

Beginner best practices.

  • Write the free at the same moment you write the malloc — before moving on.
  • Initialize owning pointers to NULL; free them and set back to NULL.
  • Free inner members before the container.
  • Run gcc -g -fsanitize=address on your programs while learning; treat any leak report as a bug to fix now.

Advanced best practices.

  • Use the goto cleanup single-exit idiom for functions with several allocations.
  • Pair every create_* with a destroy_*; document ownership transfer in the header comment.
  • Add Valgrind/ASan to continuous integration so leaks fail the build automatically.
  • For complex lifetimes, consider arena/pool allocators (free everything at once) or reference counting, and document who owns what.
  • Profile with massif (Valgrind's heap profiler) to understand where memory goes even when it is not technically leaked.

Practice tasks

Beginner 1 — Balance a single allocation. Write a function int sum_file_size(const char *name) that mallocs a 128-byte buffer, does some pretend work, and returns 0. Add three if (...) return -1; early-exit branches. Objective: make every one of those branches leak-free. Requirement: no free may be duplicated and none may be skipped. Hint: introduce a cleanup: label and goto it. Concepts: early-return leaks, single-exit idiom.

Beginner 2 — Fix the loop leak. Given for (int i=0;i<5;i++){ char *b=malloc(32); snprintf(b,32,"row %d",i); puts(b); }, modify it so it prints the same output with zero leaks. Requirement: the fix must free each allocation. Expected output: row 0row 4. Hint: where is the missing free? Concepts: overwrite/loop leak.

Intermediate 1 — Free a nested structure. Define struct user { char *name; char *email; };. Write struct user *user_new(const char *name, const char *email) (allocates the struct and copies both strings with strdup or manual malloc) and void user_free(struct user *u). Objective: user_free must leave zero bytes leaked, and calling user_free(NULL) must be safe. Requirement: verify with ASan or Valgrind. Hint: free name and email before the struct; guard against u == NULL. Concepts: ownership, nested free, defensive checks.

Intermediate 2 — Safe realloc growth. Write int *read_ints(FILE *f, size_t *out_count) that reads integers with fscanf into a dynamically growing array, doubling capacity with realloc when full. Objective: no leak on any path, including when realloc fails. Requirement: on failure, free the current buffer and return NULL; on success set *out_count. Hint: assign realloc to a temporary before overwriting the array pointer. Concepts: realloc leak, ownership, error paths.

Challenge — Balanced-allocation checker. Write a program that reads a trace string of 'a' (allocate) and 'f' (free) characters and reports whether the sequence is leak-free and valid: it is invalid if an 'f' ever occurs with nothing live (depth would go negative), and it leaks if the depth is greater than zero at the end. Print VALID, INVALID, or LEAK: n live. Input example: "aafaff" → depth goes 1,2,1,2,1,0 → VALID. "af f"-style "aff" → second f with depth 0 → INVALID. "aaf" → ends at depth 1 → LEAK: 1 live. Constraints: single pass, O(1) extra memory beyond input. Hint: a signed counter that must never go below 0 and must end at 0. Concepts: modeling leak-freedom as a balance counter (mirrors the related exercises).

Summary

  • A memory leak is a heap block that nothing points to anymore, so free can never reach it; the allocator keeps it reserved until the process exits.
  • Leaks are silent and harmless in short programs but fatal in long-running servers and embedded loops, where they grow until memory runs out and the process is killed.
  • Most leaks are ownership bugs: for every malloc name the one place that will free it. The four classic patterns are early-return, pointer overwrite (loops), lost inner pointers (nested structs), and forgetting to free in a loop.
  • Key syntax habits: the goto cleanup single-exit idiom, free(NULL) is safe, free inner members before the container, and assign realloc to a temporary so a NULL return does not orphan the old block.
  • Do not "leak because the OS cleans up" — set freed pointers to NULL, and remember leaks differ from double-free and use-after-free.
  • Find leaks automatically: AddressSanitizer (gcc -g -fsanitize=address) for a fast dev/CI loop, and Valgrind (valgrind --leak-check=full) when you cannot recompile. Compile with -g so reports point to file and line. Make leak checking a habit before every commit.

Practice with these exercises