A different idea from Valgrind
The previous guide showed how Valgrind's memcheck hunts down memory bugs. Its trick is to not run your real program at all — instead it runs your machine code inside a software-emulated CPU, watching every single memory access from the outside. That gives it x-ray vision into code it never recompiled, even libraries you have no source for. The price is steep: a program under Valgrind typically runs 10 to 50 times slower, because every load and store is being interpreted rather than executed directly. The sanitizers in this guide reach the same goal — turning silent corruption into a loud, exact report — by a completely different route.
The idea is compiler instrumentation: when you build with a sanitizer flag, the compiler itself rewrites your program, inserting tiny extra checks around the operations that can go wrong. The result is still your real native code, running directly on the real CPU — just with guard rails baked in. Because the checks are compiled in rather than emulated, the slowdown is far gentler, often around 2x, which means you can leave them on while you develop and test. The catch is the mirror image of Valgrind's strength: the sanitizers can only watch the code you recompiled with the flag. Code you did not rebuild — a precompiled library — runs uninstrumented and invisible.
ASan: a guard rail around every byte
AddressSanitizer, almost always called ASan, is the one you will reach for most. It catches exactly the memory bugs the dynamic-memory rung warned you about: a heap buffer overflow, a stack overflow past a local array, a use-after-free, a double free, and leaks at exit. You turn it on by adding one flag to both compile and link: `gcc -fsanitize=address -g -O1 main.c`. The `-g` brings in debug info so the report names files and lines; `-O1` keeps it from being painfully slow without hiding the bug.
How does it know a write is out of bounds? Through two mechanisms working together. First, ASan replaces malloc() and free() with its own versions that put a band of poisoned redzone bytes on each side of every allocation. Second, it keeps a compact side-map called shadow memory — one shadow byte describes whether each group of 8 real bytes is currently addressable. Every memory access your code makes is preceded by a compiler-inserted check: look up the shadow for this address, and if it says poisoned, stop and report. A redzone is poisoned, so the instant you step one byte past your array, the very next access trips the alarm — not later, not maybe, but right there.
That immediacy is ASan's signature. With a plain buggy program you get a segfault far from the real mistake, or no crash at all because the stray byte happened to land somewhere harmless. With ASan you get a stop at the faulting instruction, plus a readable report: the kind of error (heap-buffer-overflow), the exact line that did it, and — beautifully — a second backtrace showing where that block was allocated, and for a use-after-free, where it was freed. For a use-after-free it even tells you all three: where you allocated, where you freed, and where you just illegally touched it.
UBSan: catching the language lawyer's traps
ASan watches where you touch memory. UndefinedBehaviorSanitizer, or UBSan, watches whether your program does something C says is simply not allowed to happen at all. Recall the hard truth from the undefined-behavior rung: undefined behavior is not 'platform-dependent' and it is not 'whatever the compiler feels like.' It is a contract you broke, and the optimizer is allowed to assume you never break it. That is why a signed integer overflow or an out-of-bounds index can run fine at `-O0` and then silently corrupt at `-O2` — the compiler optimized on the assumption the UB could not occur.
UBSan turns that ghost solid. Build with `gcc -fsanitize=undefined -g main.c` and the compiler inserts a check before each operation that has a UB trap door. At runtime, the moment one would actually trigger UB, UBSan prints a precise diagnostic — file, line, and what went wrong — instead of letting the optimizer quietly build on a false assumption. It covers a wide menu: signed overflow, shifting by a width too large (`x << 40` on a 32-bit int), dividing by zero, dereferencing a null pointer, misaligned accesses, and conversions that lose meaning. Each is a separate sub-check you can enable or disable individually.
int sum(int a, int b) {
return a + b; // a + b can overflow INT_MAX -> UB
}
// built with: gcc -fsanitize=undefined -g add.c
// at runtime, calling sum(2147483647, 1) prints:
//
// add.c:2:14: runtime error: signed integer overflow:
// 2147483647 + 1 cannot be represented in type 'int'TSan: the only one that sees a race
The third sanitizer solves a problem neither Valgrind's memcheck nor the others can: the data race. Recall from the concurrency rung that a data race is two threads touching the same memory at the same time with no lock ordering them, where at least one is writing. The infuriating thing about races is that they are timing-dependent — a heisenbug that vanishes the moment you add a print or attach a debugger, because that very act changes the timing. You cannot reliably reproduce one, so you cannot reliably catch one by ordinary means.
ThreadSanitizer, or TSan, sidesteps reproduction entirely. Built with `gcc -fsanitize=thread -g main.c`, it instruments every memory access and every locking call — pthread_mutex_lock(), thread creation, joins. From these it builds a model of the happens-before relationship: which events are provably ordered by locks and which are not. If it ever sees two threads access the same location with no lock-imposed order between them and at least one write, it reports a race — even if, on this particular run, the timing happened to work out fine and nothing visibly broke. That is the deep win: TSan flags the latent race, not just the one that bit you today.
A TSan report reads like a story with two narrators: it shows the two conflicting accesses, the stack trace of each thread at the moment it touched the shared location, and which locks each thread was (and was not) holding. That last detail is gold, because the fix for a race is almost always 'hold the right lock around this access,' and TSan has just shown you precisely which access was unprotected. The honest costs: TSan uses a lot more memory (it shadows access history per location) and, like all sanitizers, only sees races among threads in your instrumented code.
Using them well
A few habits make the sanitizers pay off rather than frustrate. The first is a rule you cannot skip: ASan and TSan are mutually exclusive — you cannot combine `-fsanitize=address` and `-fsanitize=thread` in one build, because both want to own the same shadow-memory machinery. UBSan, by contrast, is friendly and can ride along with either. So the usual recipe is two builds: one with `-fsanitize=address,undefined` for memory and UB bugs, and a separate one with `-fsanitize=thread` for race hunting.
- Build a test or debug binary with the sanitizer flags on both the compile and the link command, plus -g for line numbers; keep your shipping release build separate and un-sanitized.
- Run your test suite under it. A sanitizer only reports bugs on code paths that actually execute, so the more inputs your tests exercise, the more it finds — this is why CI pipelines routinely run a sanitized build of the whole test suite.
- Read the report top to bottom: the first frame of the first backtrace is usually the line to fix, and the allocation or free or second-thread backtrace tells you the rest of the story.
- Fix the root cause, then rerun until clean — and treat any sanitizer report in CI as a hard failure, not a warning to defer, because a real bug found here is one a customer would have found in production.
Finally, keep the honest perspective the whole debugging rung has insisted on. The sanitizers are extraordinary at turning a vague crash into a named bug at a named line, but they are detectors, not proofs: they find the bugs your inputs reach, not every bug that could exist. This is precisely the kind of error class — out-of-bounds writes, use-after-free, data races, signed overflow — that the Rust language was designed to make unrepresentable in safe code, catching them at compile time rather than at runtime. That is a real and different tradeoff, not magic: Rust's borrow checker has a genuine learning curve and `unsafe` still exists. The sanitizers are how you keep C and C++ honest; understanding why they have to exist is half of understanding what they are catching.