JOVANA
Explore Library Glossary Getting Started Three Levels Fields How it works Mission
Join the mission
All guides

Memory Leaks and How to Catch Them

A memory leak is not a crash — it is the quiet opposite: memory you borrowed but never gave back, piling up invisibly until your program slowly chokes. This guide explains exactly what leaks, why the heap cannot reclaim it for you, and how Valgrind and AddressSanitizer turn an invisible bug into a printed list of lines to fix.

A different kind of bug: nothing crashes

The last two guides gave you the two halves of manual heap management. Guide 1 taught the contract: malloc() hands you a block from the heap and free() hands it back, and every block you take must eventually be returned exactly once. Guide 2 named the discipline that makes that possible — ownership: for every live block, exactly one part of your code is responsible for freeing it. This guide is about what happens when that responsibility quietly falls through the cracks. Nobody calls free(). The block is never returned. That is a memory leak.

What makes leaks unsettling is that they break the pattern you have learned to expect from C bugs. A bad pointer gives you a loud segmentation fault; an off-by-one might corrupt data you can eventually trace. A leak does neither. The program runs correctly. The output is right. Nothing crashes, nothing warns, the tests pass. The only symptom is that the program's memory footprint creeps upward over time — a number you usually are not even watching. A leak is the rare bug whose entire nature is that it produces no immediate symptom at all.

What actually leaks, byte by byte

Let us be precise about what is lost, because "leak" is a loose word. When you call malloc(64) you get back a pointer to a 64-byte block, and the allocator records, in its own bookkeeping, that those 64 bytes are now in use. That record is the only thing tracking the block. The pointer in your variable is your only handle on it — the only way back to it. A leak happens at the exact moment you lose that handle without having freed the block: the variable holding the pointer is overwritten, goes out of scope, or the function returns, and now no pointer anywhere in your whole program names those 64 bytes. Picture `char *p = malloc(64);` followed later by `p = malloc(128);` with no free() in between — that one reassignment strands the original block, because its only handle has just been painted over.

From the allocator's point of view, those bytes are still in use — it is still holding them aside for you. From your point of view, they are unreachable: you have no pointer, so you can never free them and never touch them again. They are stranded. They will stay reserved, doing nothing, until your process exits and the operating system reclaims everything at once. That last fact is the one mercy: a leak in a short-lived program that runs and quits is mostly harmless, because process exit frees the whole address space anyway. The danger is the program that does not quit.

Why a leak in a long-running program is a slow death

Run the leaky line once and you lose 64 bytes — nobody notices. The trouble is that real leaks usually live inside a loop or a request handler, somewhere that runs again and again. A web server that leaks a small block on every request, a game that leaks a few bytes every frame, a daemon that runs for months — each one strands a little more memory on every pass. The footprint does not jump; it grows, steadily, like a slow tide. This is why leaks are so often caught not by a test but by a graph: someone notices the memory line on a monitoring dashboard climbing and never coming back down.

Where does it end? Eventually the process asks the kernel for more heap than it is allowed, malloc() starts returning NULL, and — if the code dutifully checks its return value, as guide 1 insisted it must — the program fails an allocation it should have been able to satisfy. If it does not check, it dereferences that NULL and dies with a segfault far from the actual leak. Either way the crash, when it finally comes, points at an innocent allocation, not at the leaky line that exhausted the heap hours earlier. The cause and the symptom are separated by time, which is exactly what makes leaks hard to chase by reading code alone.

Catching leaks with Valgrind

Because a leak hides the lost handle from you, the way to find it is to have something else keep the bookkeeping you dropped. That is precisely what Valgrind's Memcheck does. You build your program normally — ideally with debug info, so compile with `gcc -g -O0 main.c` — and then run it under Valgrind instead of running it directly: `$ valgrind --leak-check=full ./a.out`. Valgrind runs your program on a simulated CPU, watching every malloc() and every free(). When your program exits, it knows exactly which blocks were allocated and never freed, and it prints them.

The report is the magic. For each leaked block, Valgrind shows the byte count and — this is the part that turns hours into minutes — a full stack trace of the malloc() call that allocated it, file and line included. It also sorts leaks into categories: "definitely lost" means truly unreachable (a real leak you must fix), while "still reachable" means a block you never freed but a pointer still names at exit (often harmless, freed implicitly by exit, but worth a look). You read the trace, go to the line, and add the free() that the owner was supposed to call.

==12345== HEAP SUMMARY:
==12345==    in use at exit: 64 bytes in 1 blocks
==12345==
==12345== 64 bytes in 1 blocks are definitely lost
==12345==    at 0x4848899: malloc (vg_replace_malloc.c:...)
==12345==    by 0x1091AE: main (main.c:7)        <- the leaking line
==12345==
==12345== LEAK SUMMARY:
==12345==    definitely lost: 64 bytes in 1 blocks
A Valgrind leak report. The block size, the count, and the file:line of the malloc that leaked are all handed to you — go to main.c:7 and free it.

AddressSanitizer, and finding leaks before they grow

Valgrind is thorough but slow — it can make a program run ten to thirty times slower, because it emulates the CPU. The modern alternative is AddressSanitizer (ASan), which the compiler bakes into your program when you add one flag: `gcc -g -fsanitize=address main.c`. The resulting binary checks itself as it runs, at a far lighter cost (roughly two times slower), and on exit its built-in leak detector — LeakSanitizer — prints the same kind of report: size, count, and the allocation stack trace. The same flag also catches the use-after-free and buffer-overflow bugs of the next guide, so it is the single most valuable flag to keep on while developing.

But the deepest fix is not a tool at all — it is the habit you already met in guide 2. A leak is, at root, a question of ownership left unanswered: who frees this, and when? If you can point at the exact line that owns each block's free() the moment you write the malloc(), you will rarely leak. Tools are for the cases where that discipline slipped, or where the codebase is too large to hold in your head. Walk a suspected leak with these steps.

  1. Reproduce under a tool: rebuild with `gcc -g -fsanitize=address` (or run the existing binary under `valgrind --leak-check=full`), then exercise the path you suspect and let the program exit cleanly so the leak report prints.
  2. Read the allocation stack trace to the file and line of the leaking malloc() — that is where the block was born, the start of its lifetime.
  3. Ask the ownership question: which single part of the code is responsible for this block's free(), and on every path out — early return, error branch, loop iteration — does that free() actually run?
  4. Add the missing free() on exactly the paths that lack it, set the pointer to NULL afterward if it might be used again, rebuild, and rerun under the tool until the report reads zero bytes lost.

Where this leaves you

You can now name the quietest bug in the heap precisely. A leak is a lost handle: a block the allocator still holds aside but no pointer in your program can ever reach, so it can never be freed and is stranded until the process exits. It does not crash, does not corrupt, does not warn — it just accumulates, which is exactly what makes it dangerous in anything long-running and harmless in anything short-lived. And you have two instruments — Valgrind and AddressSanitizer — that turn the invisible back into a printed file and line.

Keep one boundary clear as you move on. This guide's bug came from freeing too little — you never gave the block back. The next guide is its mirror image: bugs from using or freeing a block at the wrong time — touching memory after it has been freed (use-after-free) or freeing the same block twice (double-free). Those cross the line into undefined behavior, where the optimizer is free to assume the bug never happened, so the symptoms get genuinely strange. We will meet that strangeness honestly, and the same flag, `-fsanitize=address`, will be there to catch it.