Pointers & Memory · intermediate · ~10 min
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).
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.
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.
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
freea block once every pointer to it is lost?
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?
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); }
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?
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.
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.
You do not have to hunt for leaks by hand. Two tools do it for you:
memcheck) — runs your program and reports leaked allocations.-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.
#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").
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.malloc returns NULL, we return NULL immediately. Nothing was allocated yet in this function, so there is nothing to leak on that path.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.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.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 |
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.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.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.
Compiler / build errors.
malloc/free: you forgot #include <stdlib.h>.-fsanitize=address at compile but not at link — pass it to both steps (or just build in one gcc command).Runtime symptoms of leaks.
top, Task Manager, or /proc/<pid>/status VmRSS).Reading a Valgrind report. Run valgrind --leak-check=full --show-leak-kinds=all ./prog. Look for:
-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.
malloc, which single place is supposed to free it? If you cannot name it, that is the bug.return/break/goto/error path between the malloc and the free actually reach a free?realloc) before being freed?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.
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.
free a pointer twice; set it to NULL after freeing so a stray second free(NULL) is harmless.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.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.
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.
free at the same moment you write the malloc — before moving on.NULL; free them and set back to NULL.gcc -g -fsanitize=address on your programs while learning; treat any leak report as a bug to fix now.Advanced best practices.
goto cleanup single-exit idiom for functions with several allocations.create_* with a destroy_*; document ownership transfer in the header comment.massif (Valgrind's heap profiler) to understand where memory goes even when it is not technically leaked.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 0 … row 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).
free can never reach it; the allocator keeps it reserved until the process exits.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.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.NULL, and remember leaks differ from double-free and use-after-free.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.