Valgrind for C++: Reading Memcheck Output, Leak Kinds, and When to Use ASan Instead

Key takeaways

Valgrind's Memcheck runs an unmodified binary on a synthetic CPU and reports heap overruns, use-after-free, uninitialized reads, and leaks. It needs no recompilation, but it is Linux-centric, heavily slower than native, and blind to stack array overflows — which is why most teams pair it with AddressSanitizer rather than choosing one.

What Valgrind actually does

Valgrind is a framework that runs your program on a synthetic CPU. It translates the machine code into an intermediate representation, lets a “tool” add instrumentation, and then executes the result. Memcheck, the default tool, attaches shadow state to every byte of memory — is it addressable, has it been initialized — and checks every load, store, and conditional branch against it. It also replaces malloc, free, new, and delete so it knows where each heap block starts and ends.

Two consequences follow directly from that design, and they drive every trade-off in this article:

  • No recompilation is needed. Valgrind works on any existing binary, including third-party libraries you cannot rebuild. You only want -g for readable stack traces.
  • It is slow. Every instruction goes through translation and instrumentation. The Valgrind Quick Start Guide describes programs under Memcheck as running “much slower (eg. 20 to 30 times) than normal”, with much higher memory use. Valgrind also runs only one thread at a time, so multithreaded programs lose their parallelism entirely.

Installing and a first run

# Debian / Ubuntu
sudo apt-get install valgrind
# Fedora
sudo dnf install valgrind

valgrind --version

Platform support matters more than with most tools. Valgrind is primarily a Linux tool (x86-64, arm64, and several other architectures). Upstream support for macOS stopped at old releases, and it does not run on Apple Silicon, so brew install valgrind on a current Mac is not a working path. There is no Windows port; WSL or a Linux container works fine.

Build with debug info and low optimization:

g++ -g -O0 program.cpp -o program     # -O1 is also fine
valgrind --leak-check=full ./program

The Quick Start recommends against -O2 and above because Memcheck occasionally reports uninitialised-value errors that do not really exist in optimized code, and inlining makes stack traces harder to map to source.

Reading a leak report

// leak.cpp
int main() {
    int* ptr = new int(42);
    return *ptr - 42;          // ptr is never deleted
}
g++ -g -O0 leak.cpp -o leak
valgrind --leak-check=full ./leak

The output looks like this (addresses, PIDs, and exact byte totals differ between systems):

==31415== HEAP SUMMARY:
==31415==     in use at exit: 4 bytes in 1 blocks
==31415==   total heap usage: 2 allocs, 1 frees, 72,708 bytes allocated
==31415==
==31415== 4 bytes in 1 blocks are definitely lost in loss record 1 of 1
==31415==    at 0x4846FA3: operator new(unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==31415==    by 0x10915E: main (leak.cpp:3)
==31415==
==31415== LEAK SUMMARY:
==31415==    definitely lost: 4 bytes in 1 blocks
==31415==    indirectly lost: 0 bytes in 0 blocks
==31415==      possibly lost: 0 bytes in 0 blocks
==31415==    still reachable: 0 bytes in 0 blocks
==31415==         suppressed: 0 bytes in 0 blocks

How to read it:

  • ==31415== is the process ID. It prefixes every Valgrind line so you can separate them from your program’s own output.
  • The stack trace under a leak is the allocation site, not the place where the pointer was lost. Valgrind cannot know where you “should” have freed it; it only knows who allocated it. The first frame in your own code (main (leak.cpp:3)) is where to start.
  • The extra allocation in total heap usage that you did not write is libstdc++ reserving its emergency pool for exception objects at startup. On older toolchains it appeared as tens of kilobytes “still reachable”; newer versions free it at exit. Seeing a mystery block of that size is normal, not a leak in your code.

The four leak kinds

The leak summary is where people most often misjudge severity.

KindWhat Memcheck found at exitWhat to do
definitely lostNo pointer to the block exists anywhereReal leak. Fix it.
indirectly lostOnly reachable through blocks that are themselves lostFix the parent leak; these usually disappear with it
possibly lostOnly an interior pointer (into the middle of the block) existsInvestigate; can be real, or a custom allocator / tagged pointer
still reachableA pointer still exists (global, static, singleton) but nobody freed itUsually intentional process-lifetime memory

Indirect leaks are easiest to understand with a linked list:

// list.cpp
struct Node { int value; Node* next; };

int main() {
    Node* head = nullptr;
    for (int i = 0; i < 3; ++i) head = new Node{i, head};
    head = nullptr;   // lose the only pointer to the list
}

Memcheck reports the first node as definitely lost (nothing points to it) and the other two as indirectly lost (something points to them — but that something is itself lost). Fixing the head leak fixes all three, which is why you work on definite leaks first and re-run.

Still reachable is the one that generates the most noise and the most arguments:

static std::vector<int*>* registry = new std::vector<int*>();   // never deleted

This is a deliberate pattern (a leaky singleton avoids static destruction order problems), and freeing everything at exit just to satisfy a tool slows shutdown for no benefit. By default, --leak-check=full shows details only for definite and possible leaks; add --show-leak-kinds=all when you want to audit everything, and do not treat a still-reachable count as a bug by itself. Where it does matter is a long-running server: a still-reachable block that grows on every request (a cache without eviction, a registry nobody prunes) is a leak in every practical sense, even though Valgrind classifies it as reachable because the pointer is still there.

Invalid access and mismatched delete

// invalid.cpp
#include <cstdio>
int main() {
    int* arr = new int[10];
    for (int i = 0; i <= 10; ++i) arr[i] = i;   // i == 10 is one past the end
    std::printf("%d\n", arr[0]);
    delete arr;                                  // should be delete[]
}
==31416== Invalid write of size 4
==31416==    at 0x1091C4: main (invalid.cpp:5)
==31416==  Address 0x4e2e0a8 is 0 bytes after a block of size 40 alloc'd
==31416==    at 0x4849013: operator new[](unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==31416==    by 0x10919E: main (invalid.cpp:4)
==31416==
==31416== Mismatched free() / delete / delete []
==31416==    at 0x484A164: operator delete(void*, unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==31416==    by 0x10920B: main (invalid.cpp:7)
==31416==  Address 0x4e2e080 is 0 bytes inside a block of size 40 alloc'd
==31416==    at 0x4849013: operator new[](unsigned long) (in /usr/libexec/valgrind/vgpreload_memcheck-amd64-linux.so)
==31416==    by 0x10919E: main (invalid.cpp:4)

Each error has two stacks: where the bad access happened and where the block involved came from. The phrase between them is the most useful part:

  • 0 bytes after a block of size 40 — classic off-by-one, writing element 10 of a 10-element int array.
  • N bytes inside a block of size M free'd — use-after-free; a third stack shows where it was freed.
  • Address 0x0 is not stack'd, malloc'd or (recently) free'd — null pointer dereference, typically followed by a SIGSEGV.

Note the example uses a heap array on purpose. If you write past a local int arr[10];, Memcheck will usually say nothing: the out-of-bounds slot is still valid stack memory as far as it can tell. This is one of the biggest gaps compared to AddressSanitizer, which puts redzones around stack and global objects and reports stack-buffer-overflow.

Uninitialized values

Compilers already warn about the trivial int x; if (x > 0) case. Valgrind earns its keep when the uninitialized data crosses a function boundary where static analysis loses track:

// uninit.cpp
#include <cstdio>

struct Config { int retries; bool verbose; };

Config load(bool fromFile) {
    Config c;                        // members left uninitialized
    if (fromFile) { c.retries = 3; c.verbose = true; }
    return c;
}

int main(int argc, char**) {
    Config cfg = load(argc > 1);
    if (cfg.verbose)                 // undefined when run without arguments
        std::puts("verbose");
}

This compiles cleanly with -Wall -Wextra. Under Valgrind:

valgrind --track-origins=yes ./uninit
==31417== Conditional jump or move depends on uninitialised value(s)
==31417==    at 0x109189: main (uninit.cpp:14)
==31417==  Uninitialised value was created by a stack allocation
==31417==    at 0x109169: main (uninit.cpp:12)

Two things surprise people here. First, Memcheck does not complain when uninitialized memory is merely copied — return c; and Config cfg = ... are silent. It only reports when the value affects control flow (if), is used as an address, or is passed to a system call. That avoids false positives from copying padding bytes, but it means the report can be far from the real mistake. Second, without --track-origins=yes you get only the first two lines and no hint of where the value came from. The option makes Memcheck slower still, so enable it when you are chasing an uninitialized-value report, not by default.

I have lost real time to exactly this shape of bug: a struct that is fully initialized on the common path and partially on a rare one, where the program behaves correctly in every debug build because the stack happens to contain zeros, and then misbehaves only in release. Valgrind’s report pointed at the if, several calls away from the missing initializer, and it was --track-origins=yes that finally led back to the stack allocation.

Suppressions for noise you cannot fix

Third-party libraries (graphics drivers, some SSL and GUI stacks) can produce reports you have no way to fix. Generate suppression entries rather than learning to ignore output:

valgrind --leak-check=full --gen-suppressions=all --log-file=vg.log ./program

Valgrind prints its messages on stderr, not stdout, so redirecting stdout to a file captures nothing — use --log-file. Each report in the log is followed by a block you can copy into a .supp file:

{
   thirdparty_init_leak
   Memcheck:Leak
   match-leak-kinds: definite
   fun:_Znwm
   fun:_ZN10thirdparty4initEv
   ...
}

_Znwm is the mangled name of operator new(unsigned long). The ... line matches any number of further frames, which keeps the suppression working if the call path above the library changes. Then:

valgrind --suppressions=project.supp --leak-check=full ./program

Keep suppressions narrow — match on a function inside the library, never on fun:malloc alone, or you will suppress your own leaks too. Commit the file so the whole team sees the same baseline.

Using Valgrind in CI

By default Valgrind exits with your program’s exit code, so a job that “runs under Valgrind” will pass even when Memcheck reported errors. Make failures explicit:

valgrind --leak-check=full \
         --errors-for-leak-kinds=definite \
         --error-exitcode=1 \
         --suppressions=project.supp \
         ./unit_tests

Because of the slowdown, this usually runs on unit tests or a small input set rather than the full integration suite.

If you need to inspect state at the moment of an error, Valgrind has a built-in gdbserver: run with --vgdb=yes --vgdb-error=0, then in another terminal gdb ./program and target remote | vgdb. Execution stops at each error, with the real variable values available.

The other tools

Memcheck is the default, but the same framework ships several tools selected with --tool=:

ToolPurposeViewer
memcheckMemory errors and leakstext output
massifHeap usage over time, peak and who allocated itms_print massif.out.<pid>
callgrindInstruction-level call-graph profilingcallgrind_annotate, KCachegrind
cachegrindInstruction counts, optional cache simulationcg_annotate
helgrind / drdData races and lock-order problemstext output

Callgrind and Cachegrind count instructions rather than measuring wall time, which makes results deterministic and good for comparing two versions of a function, but they will not show you time spent waiting on I/O or locks. Recent Valgrind releases turned Cachegrind’s cache simulation off by default; pass --cache-sim=yes if you want the cache miss columns.

Valgrind or AddressSanitizer?

This is the practical question most people arrive with. They overlap but are not substitutes:

Valgrind MemcheckAddressSanitizer
Rebuild neededNoYes, -fsanitize=address for everything you want checked
SlowdownLarge (Quick Start: roughly 20-30x)Small (Google docs: typically about 2x)
Stack / global overflowsMostly not detectedDetected
Uninitialized readsDetectedNot detected (needs MemorySanitizer, Clang only)
Leak detectionYes, with leak kindsYes (LeakSanitizer)
ThreadsSerializedRun in parallel
PlatformsLinux (old macOS only)Linux, macOS, Windows (MSVC)

The two cannot be combined: an ASan-instrumented binary will not run correctly under Valgrind. In practice, ASan (plus UBSan) is the everyday tool because it is fast enough to run on every test build; Valgrind is what you reach for on binaries you cannot rebuild, when you need uninitialized-read detection with GCC, or when you want Massif’s heap profile. See C++ sanitizers for the ASan/UBSan/TSan side.

Fixing leaks for good

Valgrind tells you where memory was allocated; the lasting fix is ownership that frees itself. The raw-pointer vector below leaks five blocks (definitely lost) even though the vector’s own buffer is freed correctly:

std::vector<int*> vec;
for (int i = 0; i < 5; ++i) vec.push_back(new int(i));
// vector destructor frees its array of pointers, not what they point to

Adding a manual delete loop fixes the report but breaks again the moment an exception is thrown between allocation and cleanup. Owning the elements fixes both:

std::vector<std::unique_ptr<int>> vec;
for (int i = 0; i < 5; ++i) vec.push_back(std::make_unique<int>(i));
// each element is freed when vec is destroyed, including during stack unwinding

Running that version prints All heap blocks were freed -- no leaks are possible, which is the line you want to see at the end of every Memcheck run.