Find Memory Leaks in C with Valgrind — A Practical Tutorial

The scariest C bugs are the ones that don’t crash. A program can leak memory on every iteration, write past the end of a buffer, and still print the right answer and exit 0 — until it runs long enough to matter. Valgrind catches exactly this class of bug: it runs your program under a microscope and reports every leaked byte and every illegal memory access, with file and line numbers. This tutorial finds two real bugs in a program that “works fine”, using actual output from Valgrind 3.22 on Ubuntu 24.04.

Step 1 — Install Valgrind

sudo apt install valgrind

(Fedora: sudo dnf install valgrind.) Verify:

valgrind --version
valgrind-3.22.0

One platform note: Valgrind is a Linux tool. It doesn’t support modern macOS at all — on a Mac, run it inside Docker or a Linux VM (that’s exactly how the output in this article was captured), or use the compiler’s built-in AddressSanitizer as an alternative.

The Program That “Works Fine”

Two bugs are hiding in leaky.c — a leak and a buffer overflow:

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

char *make_greeting(const char *name)
{
    char *buf = malloc(64);
    sprintf(buf, "Hello, %s!", name);
    return buf;
}

int main(void)
{
    int i;
    for (i = 0; i < 3; i++) {
        char *msg = make_greeting("world");
        printf("%s\n", msg);
        /* bug 1: msg is never freed */
    }

    int *data = malloc(5 * sizeof(int));
    data[5] = 42;              /* bug 2: writes one past the end */
    free(data);

    return 0;
}

Compile with -g (Valgrind uses debug symbols for its line numbers, same as GDB) and run it normally:

$ gcc -g -Wall -Wextra -o leaky leaky.c
$ ./leaky
Hello, world!
Hello, world!
Hello, world!
$ echo $?
0

Correct output, clean exit, zero compiler warnings. Ship it? Let’s ask Valgrind first.

Step 2 — First Valgrind Run

No recompilation needed — Valgrind wraps the existing binary:

valgrind ./leaky
==100== Memcheck, a memory error detector
==100== Command: ./leaky
==100==
==100== Invalid write of size 4
==100==    at 0x1088E8: main (leaky.c:22)
==100==  Address 0x4a7f214 is 0 bytes after a block of size 20 alloc'd
==100==    at 0x4885250: malloc (in vgpreload_memcheck.so)
==100==    by 0x1088D7: main (leaky.c:21)
==100==
Hello, world!
Hello, world!
Hello, world!
==100==
==100== HEAP SUMMARY:
==100==     in use at exit: 192 bytes in 3 blocks
==100==   total heap usage: 5 allocs, 2 frees, 4,308 bytes allocated
==100==
==100== LEAK SUMMARY:
==100==    definitely lost: 192 bytes in 3 blocks
==100== ERROR SUMMARY: 1 errors from 1 contexts (suppressed: 0 from 0)

(The ==100== prefix is just the process ID; your number will differ.) Both bugs are already visible. Reading the Invalid write block from top to bottom, it says: an illegal 4-byte write happened at leaky.c:22, to an address “0 bytes after a block of size 20” that was malloc’d at leaky.c:21. Translated: line 21 allocated 20 bytes (five ints), and line 22 wrote the sixth — data[5] = 42, one element past the end. Valgrind caught the overflow the compiler, the OS, and the exit code all missed.

And the HEAP SUMMARY shows the leak: 5 allocations, only 2 frees (Valgrind counts one internal allocation from printf‘s buffering) — 192 bytes still in use at exit.

Step 3 — Find the Leak’s Source: –leak-check=full

The summary says what leaked; --leak-check=full says where from:

valgrind --leak-check=full ./leaky
==101== 192 bytes in 3 blocks are definitely lost in loss record 1 of 1
==101==    at 0x4885250: malloc (in vgpreload_memcheck.so)
==101==    by 0x10886B: make_greeting (leaky.c:7)
==101==    by 0x1088AB: main (leaky.c:16)

Read the stack bottom-up: main at line 16 called make_greeting, which malloc’d at line 7 — and that memory never got freed. Three calls around the loop, 64 bytes each: 192 bytes, matching the summary exactly. The caller was supposed to free the returned buffer and didn’t.

Step 4 — Fix Both, Verify Clean

for (i = 0; i < 3; i++) {
    char *msg = make_greeting("world");
    printf("%s\n", msg);
    free(msg);                        /* fix 1 */
}

int *data = malloc(6 * sizeof(int)); /* fix 2: room for data[5] */
data[5] = 42;
free(data);
$ gcc -g -Wall -Wextra -o fixed fixed.c
$ valgrind --leak-check=full ./fixed
==108== HEAP SUMMARY:
==108==     in use at exit: 0 bytes in 0 blocks
==108==   total heap usage: 5 allocs, 5 frees, 4,312 bytes allocated
==108==
==108== All heap blocks were freed -- no leaks are possible
==108==
==108== ERROR SUMMARY: 0 errors from 0 contexts (suppressed: 0 from 0)

“All heap blocks were freed — no leaks are possible” is the sentence you’re working toward. Make a clean Valgrind run part of your definition of done, exactly like a passing test suite.

Reading the Leak Categories

Category Meaning Severity
definitely lost No pointer to the block exists anymore — a true leak Fix it
indirectly lost Reachable only through a definitely-lost block (e.g., a leaked list’s nodes) Fixing the definite leak usually fixes these
possibly lost Only an interior pointer (into the middle of the block) remains Usually treat as a leak
still reachable A pointer still exists at exit — memory just wasn’t freed before quitting Cosmetic for short-lived programs; matters for libraries and daemons

Flags Worth Knowing

  • --leak-check=full — per-leak stack traces (use it always)
  • --track-origins=yes — for “use of uninitialised value” errors, shows where the garbage came from; slower but decisive
  • --show-leak-kinds=all — include still reachable blocks in the detailed report
  • -s — list every error context at the end, including suppressed ones

Expect your program to run 10–30× slower under Valgrind — it’s re-executing every instruction under supervision. That’s fine for tests; don’t benchmark under it.

Common Issues

Problem Fix
Errors show ??? instead of line numbers Compile with -g (and prefer -O0 while investigating)
“Invalid read” inside strlen/printf frames The bug is in your frame below it — library code is reading memory you handed it; read down the stack
Huge “still reachable” from libraries Normal — glibc and friends keep caches; focus on definitely lost
valgrind: command not found on macOS Unsupported on modern macOS — use Docker/a Linux VM, or AddressSanitizer (gcc -fsanitize=address)
Program too slow under Valgrind Expected (10–30×) — run it on tests, not production workloads

What’s Next

Valgrind tells you a leak exists and where it was allocated; stepping through the code path that should have freed it is a job for the debugger — see How to Use GDB to Debug C Programs. Building multi-file projects? Wire both tools into a proper build with the Makefile tutorial.

To understand the bug patterns Valgrind catches, our quiz explainer series demonstrates them one by one: use-after-free, freeing an invalid pointer, and out-of-bounds indexing.


As an Amazon Associate we earn from qualifying purchases.

Recommended Book

Manual memory management done right is a discipline — learn it from the source: The C Programming Language by Kernighan & Ritchie (Amazon.com), whose malloc chapter is still the clearest ever written.

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <s> <strike> <strong>