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.