The Three Villains
Almost every pointer bug you'll spend your career debugging is one of three shapes. Learn to name them, learn to spot them, learn a small set of defensive habits, and you'll write C that your future self doesn't hate.
The shape of every pointer bug
If you've been following along, you already know that a pointer is a wire to a location in memory, and the wire is only useful if something valid lives at that location. Most pointer bugs come from situations where that second half fails. Either the wire never went anywhere meaningful, or it went somewhere that used to exist and doesn't anymore, or it was deliberately pointed at nothing and somebody forgot to check. Three shapes. Three villains.
Once you know their names, you start seeing them everywhere. Code reviews get faster. Your own bugs get easier to reproduce. And you develop the reflex to ask "which villain am I staring at?" before you try to fix anything, because the fix depends on which one it is.
Villain 1: the null pointer
The null pointer is the most well-behaved of the three. It's a defined state, written as NULL in C or nullptr in C++. It means "this pointer is deliberately pointing at nothing." You use it all the time: a function that couldn't find something returns null, a linked list's last node points to null, a data structure's empty slot is null.
The problem with null isn't that it exists. It's that if you forget to check for it before dereferencing, your program crashes. Most operating systems map address zero to nothing, so reading or writing through a null pointer triggers a segfault on Linux or an access violation on Windows.
Node* find(Node* head, int key) { while (head) { if (head->key == key) return head; head = head->next; } return NULL; // not found, return null } Node* n = find(list, 42); printf("%d\n", n->key); // BUG if find returned NULL
The fix is a null check. Boring, mechanical, and you'll write thousands of them.
Node* n = find(list, 42); if (n != NULL) { printf("%d\n", n->key); } else { printf("not found\n"); }
The null pointer isn't really a villain on its own. It's the forgotten null check that causes the bug. Tony Hoare, who invented null references in 1965, later called it "the billion dollar mistake" precisely because of how many programs crashed from unchecked nulls over the decades. Modern languages like Rust and Kotlin tried to fix this at the type-system level. In C and C++, we're still manually checking.
One distinction that trips people up: dereferencing a null pointer is undefined behaviour, usually a crash. This is very different from free(NULL), which is defined by the standard as a safe no-op. Easy to mix these up when you've just learned the "NULL after free" pattern. The pointer being null is a safe state to hold and compare against. Reading or writing through a null pointer is not. One rule to remember: free(NULL) is fine, *NULL is catastrophe.
Villain 2: the wild pointer
A wild pointer is one that was declared but never given a value. In C, local variables don't get auto-initialised. A declared but uninitialised pointer holds whatever bytes happened to be sitting in that stack slot when the function started running, which is usually leftover garbage from some other function that ran before.
void scary(void) { int* p; // uninitialised! holds random bytes. *p = 42; // write 42 to... wherever those bytes point. UB. }
Dereferencing a wild pointer is one of the most dangerous things you can do in C. The specific behaviour depends on what garbage happens to be there. Sometimes the bytes point at an unmapped address and you get a clean crash. Sometimes they point at your own stack or heap and you silently corrupt another variable. Sometimes they point at read-only memory and you get a crash on write but not on read. And sometimes, purely by chance, they point somewhere harmless and your program appears to work, which is the worst outcome of all.
The fix is to never let a wild pointer exist. Always initialise pointers at the point of declaration.
void safer(void) { int* p = NULL; // no mystery. p is null until we give it a real target. // now at least we know what p is. if we dereference by mistake, // we crash immediately instead of corrupting silently. }
This is also why some style guides ban declaring pointers without initialising them, and why modern C and C++ both push you to declare variables as close to their use as possible. If you can write int* p = &x; or int* p = malloc(...) on the same line you declare p, do it. The window for wildness closes.
NULL counts. Garbage doesn't.Villain 3: the dangling pointer
The dangling pointer is the trickiest of the three, because it was valid once. You had a perfectly good pointer, and then something happened to the memory it pointed at, and now the pointer is aimed at a grave.
Three common ways this happens, each introduced in earlier posts:
- Returning a pointer to a local. The local lived on a stack frame that got popped when the function returned. Pointer still holds the address, but the frame is gone.
- Use-after-free. You had a heap allocation, you called
free, and then you kept using the pointer. The allocator may have already reused those bytes for somebody else. - Escape from scope. You took a pointer to a local variable inside a
{ ... }block, and the block ended. The local is gone even though the enclosing function hasn't returned yet.
All three are variations on the same theme: the memory the pointer pointed at had a lifetime, and the pointer is now outside that lifetime. Dereferencing it is undefined behaviour, and as we emphasised in Part 4, undefined behaviour often looks like "works fine during development, breaks in production."
// Dangling type 1: return pointer to local char* bad1(void) { char buf[32]; strcpy(buf, "hi"); return buf; // buf dies at return. pointer is dangling. } // Dangling type 2: use-after-free void bad2(void) { int* p = malloc(sizeof(int)); *p = 42; free(p); printf("%d\n", *p); // use-after-free. UB. } // Dangling type 3: pointer to a scope that ended void bad3(void) { int* p; { int temp = 99; p = &temp; // take address of something about to die } // temp is gone. p is dangling. printf("%d\n", *p); // UB }
Dangling pointers are a more subtle bug than wild pointers because they used to work. You probably wrote the code that dereferences them before you wrote the code that invalidates them, and you tested the "before" path and it was fine, so the bug slipped in through the "after." Tests that only cover the happy path won't catch it.
Six short snippets. Read each one and guess which villain is hiding. Click to commit an answer. Do this a few times and you'll start spotting the pattern faster than you can read the code. That's the goal, because real bugs rarely announce themselves.
Defensive patterns that save lives
Now for the practical part. There's a small set of habits that, once they become automatic, will prevent most pointer bugs before you even write the rest of the function. None of them are clever. All of them are mechanical. That's the point.
Pattern 1: Always initialise
Never declare a pointer without immediately giving it a value. If you don't have a real target yet, use NULL or nullptr. This single habit eliminates every wild pointer.
// Bad: int* p; // Good: int* p = NULL; // Best, when possible: declare with its real value on the same line int* p = &some_variable;
Pattern 2: NULL after free
After calling free, immediately set the pointer to NULL. This prevents use-after-free (dereferencing now crashes clearly instead of silently corrupting) and makes double-free safe (because free(NULL) is a no-op).
free(p); p = NULL; // now any bug where we touch p after this crashes loudly instead of corrupting memory // and if we accidentally call free(p) again, it's a safe no-op
Some shops even wrap this in a macro:
#define SAFE_FREE(p) do { free(p); (p) = NULL; } while (0) SAFE_FREE(buffer); // frees and nulls in one step
Pattern 3: Check before you dereference
Any pointer that might be null should be checked before use. Yes, this gets tedious. Yes, you should do it anyway. In hot code where the check hurts, use an assertion instead, which documents the invariant and crashes loudly in debug builds.
// Defensive check: if (p != NULL) { *p = 5; } // Contract assertion: "caller promised p is not null; crash if it is" #include <assert.h> void process(int* p) { assert(p != NULL); // compiled out in release builds *p = 5; }
Assertions aren't a replacement for checks. They document the invariant and catch violations in testing. In production, an assertion is typically compiled out. So for input you receive from the outside world (network, files, user input), use real checks. For internal invariants you expect every caller to honour, use assertions.
Pattern 4: Early return on failure
When a function gets a null pointer as input, return early. Don't try to be clever. This keeps the happy path clean and obvious.
void print_name(Person* p) { if (!p) return; // bail on null if (!p->name) return; // bail if name is missing printf("%s\n", p->name); }
The alternative, deeply nested if blocks, turns into a pyramid of doom and makes the real logic hard to find. Early returns, sometimes called guard clauses, keep the function's intent clear.
Pattern 5: Scope ownership tightly
Don't pass pointers around farther than necessary. The longer a pointer's value travels, the more paths you need to reason about. If a function only needs a value, pass the value. If a function needs to modify one thing, pass just that one pointer. Small surfaces are easier to keep safe.
Each pattern shuts down a specific class of bug. Initialise-to-NULL kills wild pointers. Null-check kills unchecked-null dereferences. NULL-after-free kills use-after-free and double-free. Assertions catch contract violations in testing. Turn them all on and the same function becomes dramatically harder to misuse.
Lifetimes of the three dangling flavours
Because dangling is the most subtle of the three, it helps to see all three of its variants side by side on a timeline. Different origins, same shape of bug: a pointer that outlived its target.
All three pointers are born valid. All three eventually become dangling. The cause is different, but the shape of the bug is the same: the memory the pointer referenced had a lifetime, and the pointer survived past the end of that lifetime. Whether you call it "return pointer to local," "use after free," or "pointer to a scope that ended," the diagnosis is dangling.
A word on tools
Eventually you'll have a hard-to-find pointer bug that your eyeballs missed. That's what tools are for. A few worth knowing about by name, even though we'll spend a whole post on them later:
- Compiler warnings. Turn on
-Wall -Wextra -Wpedanticfor GCC and Clang, and/W4for MSVC. These catch a surprising number of obvious bugs at compile time, including uninitialised reads and some use-after-free patterns. - AddressSanitizer (ASan). A runtime sanitiser built into GCC and Clang. Compile with
-fsanitize=addressand it will catch use-after-free, buffer overflows, and double-frees with precise stack traces. Slows your program down maybe 2x, but catches bugs your tests couldn't. - Valgrind. An older heavyweight dynamic analysis tool. Slower than ASan but doesn't require recompiling. Finds leaks, uninitialised reads, and memory errors. Still valuable for legacy code.
- UBSan (UndefinedBehaviorSanitizer). Catches a different class of UB: integer overflow, null dereferences, invalid alignment. Pairs well with ASan.
Turn these on during development and CI, keep them off in production builds. The mental model: sanitisers are truth serum for your code. The bugs they find were already there.
The C++ note
C++ inherits every villain from C but gives you a handful of tools to eliminate them at the language level.
- References (
int&) can't be null, can't be reseated, and must be initialised at declaration. They kill two villains (null and wild) outright, at the cost of some flexibility. - Smart pointers (
std::unique_ptr,std::shared_ptr) encode ownership in the type system. They free their target automatically when they go out of scope, which eliminates most use-after-free and double-free bugs by construction. - RAII more generally ties resource lifetimes to object lifetimes, so "the pointer is still alive but the thing it points at was freed" becomes structurally harder to write.
We'll dig into smart pointers in Part 9. For now, if you're writing modern C++, prefer references where possible, use smart pointers for heap allocations, and reach for raw pointers only when a function specifically needs "non-owning, possibly null."
Summing up
Almost every pointer bug in C is one of three shapes. Null is a deliberate "nowhere," safe to hold but dangerous to dereference without checking. Wild is an uninitialised pointer, full of garbage, and dereferencing one can do literally anything. Dangling is a pointer that used to be valid but now points at freed memory, popped stack frames, or exited scopes.
The defensive patterns that kill these are small and boring: initialise every pointer at declaration, null it after free, check before dereferencing, assert what you expect, and return early on failure. None of these are clever. All of them become automatic with practice. Together, they close the door on most pointer bugs before you even finish writing the function.
If you can't tell which villain you're staring at, you can't fix it. That's why naming them matters.
What's next
We've covered the main mechanics of pointers: what they are, what they point at, how they live, and how they die. The next few posts are about the quieter side of the language, where good pointer code starts sliding into elegant pointer code. Part 6 is about pointers and functions: passing them in, receiving them out, function pointers, and the callback pattern that holds together most of the standard library. It's where pointers stop being "a thing variables do" and start being "how programs compose."
Test yourself
Six questions covering the three villains and the defensive patterns. Get at least four and you're ready for Part 6.
void f(void) { int* p; *p = 5; }
p is declared but never initialised. It holds whatever garbage happens to be in its stack slot. Dereferencing it writes 5 to some unpredictable address. Could crash, could silently corrupt memory, could appear to work. This is classic wild-pointer territory, and the fix is to initialise p at declaration: int* p = NULL; or int* p = &some_int;.
int* p = malloc(sizeof(int)); *p = 10; free(p); printf("%d\n", *p);
free(p). After the free, the memory belongs to the allocator again, and the allocator is free to reuse it for the next malloc. Reading *p now is undefined behaviour. Classic "might work, might crash, might silently return junk." The fix: set p = NULL; after free, and either don't try to read through it, or check for null first.
free(NULL) is a defined no-op.
When you set p = NULL; immediately after free(p);, any later call to free(p) becomes free(NULL), which the C standard explicitly defines as doing nothing. So a double-free becomes a no-op instead of corrupting the allocator's bookkeeping. This same pattern also helps with use-after-free: dereferencing NULL usually crashes immediately with a clear segfault, rather than silently reading stale data or someone else's memory. Two bugs, one tiny habit.
B false. That's a dangling pointer. A wild pointer was never initialised at all. Different villain.
C true. Dereferencing an uninitialised pointer is always UB. No exceptions.
D true. All three are dangling-pointer origins. Same shape of bug, different cause.
E false. UB is "no guarantees," not "always crashes." Code that works three times can fail the fourth, in production, at the worst possible moment.
void process_input(char* user_buffer) { assert(user_buffer != NULL); // ... use user_buffer ... }
user_buffer comes from outside the program.
Assertions are contract checks. They document "I expect my callers to honour this invariant, and if they don't, crash loudly during development." In most build systems, assertions are compiled out in release builds (via -DNDEBUG). So in production, assert(user_buffer != NULL) does nothing, and if a null somehow sneaks in, you hit UB.If
user_buffer comes from the outside world (network, files, user input, any untrusted source), you need a real null check, not an assertion. Use an early return: if (!user_buffer) return;. Assertions are for invariants between parts of your own code, where you can guarantee the caller upheld the contract. External data needs a real check. The general rule: assertions validate assumptions, real checks handle possibilities.
void do_work(void) { int* buf; if (something()) { buf = malloc(100); } // ... other code ... *buf = 42; free(buf); }
something() returns false, buf is wild, and both dereferences are UB.
When something() returns false, buf is never assigned. It holds garbage. Both *buf = 42 and free(buf) then operate on that garbage. The free in particular could corrupt the heap in spectacular fashion.Two patterns fix this:
1. Initialise to NULL. Declare as
int* buf = NULL;. Now if something() returns false, buf stays null, and free(NULL) is a safe no-op. You still need to avoid the *buf = 42 dereference though.2. Check before dereferencing. Guard the
*buf = 42 with if (buf != NULL). Or better, bail early: if the allocation is required for the function to do anything useful, return as soon as you know it didn't happen.Together, these two patterns turn a potential heap corruption into a function that safely does nothing when it can't do its work.
Comments