Tracking Down C++ Memory Leaks: Five Dangerous new/delete Patterns, Valgrind and ASan
Introduction: Friday 5 PM—the server stopped responding
A memory leak took down our server
Two weeks after launch, Friday 5 PM, the server stopped responding. Restart fixed it until every 2–3 hours it froze again.
What we checked:
- CPU: ~10% (fine)
- Disk: 50GB free (fine)
- Memory: 500MB at start → 7.8GB in 3 hours → crash
Cause: memory leak—allocated memory never freed, like a dripping pipe until the tank overflows.
Flow: allocate → miss
deleteon error paths → leak → OOM. Fix mindset: RAII ties allocation to object lifetime—like an automatic door that closes when you leave the room. std::unique_ptr and containers own their memory and release on scope exit, so you do not hunt everyreturnfor a matchingdelete.
flowchart LR
subgraph cause[Cause]
N[new without]
R[return/exception]
N --> R
R --> L[missed delete]
end
subgraph detect[Detect]
V[Valgrind]
A[AddressSanitizer]
end
subgraph fix[Fix]
U[unique_ptr]
RAII[RAII]
end
cause --> detect --> fix
Somewhere we new’d and never delete’d; memory grew until the process died.
Buggy pattern (found after 3 days):
void processRequest(const std::string& data) {
User* user = new User(data); // heap allocation
if (!user->isValid()) {
return; // no delete — leak!
}
user->process();
delete user; // only on happy path
}
Fix (paste and build: g++ -std=c++17 -o leak_fix leak_fix.cpp && ./leak_fix):
// Paste after: g++ -std=c++17 -o leak_fix leak_fix.cpp && ./leak_fix
#include <memory>
#include <iostream>
#include <string>
struct User {
std::string data;
explicit User(const std::string& d) : data(d) {}
bool isValid() const { return !data.empty() && data != "invalid"; }
void process() { std::cout << "processed " << data << "\n"; }
};
void processRequest(const std::string& data) {
auto user = std::make_unique<User>(data);
if (!user->isValid()) {
return; // automatic cleanup
}
user->process();
}
int main() {
processRequest("hello");
processRequest("invalid");
return 0;
}
Output: processed hello only (invalid returns early with no extra output).
Takeaway: Prefer std::unique_ptr / std::make_unique so ownership is clear and every path frees memory. See smart pointers.
After reading:
- Understand risky
new/deletepatterns - Detect and fix leaks
- Learn production-style bug stories
- Use Valgrind and AddressSanitizer
More scenarios
- Game server:
removePlayer()skipped →Player*leaks → OOM hours later. - Image batch:
new unsigned char[...]+throwon parse error → many buffers leaked. - LRU cache:
map<Key, Value*>evicts witheraseonly → objects not deleted. - Callbacks:
new Callback()registered, never unregistered → leak.
How new and delete work
What new does
- Allocate with
operator new - Call constructor
- Return pointer—you must
deleteexactly once (or use smart pointers).
What delete does
- Call destructor
- Release memory with
operator delete
Never mix malloc/free with C++ objects
Use new/delete so constructors/destructors run—avoid malloc/free for C++ objects.
Five dangerous patterns
Double delete
int* ptr = new int(42);
delete ptr;
delete ptr; // undefined behavior
Fix: delete ptr; ptr = nullptr; or use smart pointers.
Dangling pointer
int* ptr1 = new int(42);
int* ptr2 = ptr1;
delete ptr1;
ptr1 = nullptr;
std::cout << *ptr2; // use-after-free
Fix: shared_ptr or clear ownership rules.
Leak on early return
void function() {
int* ptr = new int(42);
if (someCondition) {
return; // leak
}
delete ptr;
}
Fix: std::unique_ptr.
delete vs delete[]
int* arr = new int[100];
delete arr; // wrong — use delete[]
Correct: delete[] array; or std::vector / make_unique<int[]>(n).
Exception safety
void processFile(const std::string& filename) {
char* buffer = new char[1024];
std::ifstream file(filename);
if (!file) {
throw std::runtime_error("File not found");
// delete[] never runs
}
file.read(buffer, 1024);
delete[] buffer;
}
Fix:
auto buffer = std::make_unique<char[]>(1024);
// ...
Real cases
Containers of raw pointers
#include <memory>
#include <vector>
class Resource {
public:
Resource() : data(new int[100]) {}
~Resource() { delete[] data; }
Resource(const Resource&) = delete;
Resource& operator=(const Resource&) = delete;
private:
int* data;
};
void leak() {
std::vector<Resource*> resources;
for (int i = 0; i < 10; i++) {
resources.push_back(new Resource());
}
} // vector frees its array of pointers; the 10 Resources stay on the heap
void noLeak() {
std::vector<std::unique_ptr<Resource>> resources;
for (int i = 0; i < 10; i++) {
resources.push_back(std::make_unique<Resource>());
}
} // each unique_ptr's destructor deletes its Resource
The vector’s own destructor runs correctly in leak(). The problem is that a std::vector<Resource*> has no idea it is supposed to own what the pointers point to, so it destroys the pointers themselves (trivial, nothing happens) and leaves every Resource unreachable on the heap. It compiles cleanly and passes every test that does not look at memory, which is why the pattern tends to survive until someone runs the code under Valgrind. Making the element type std::unique_ptr<Resource> puts ownership into the type: destroying the vector’s elements now really means destroying the objects.
The same trap appears with clear() and erase(). Both destroy the elements of the container, and for vector<T*> or map<Key, T*> the elements are just pointers:
// Raw pointers: you must delete before clearing
for (auto* p : vec) delete p;
vec.clear();
// Owning element type: clear() is enough
std::vector<std::unique_ptr<Item>> owned;
owned.clear(); // deletes every Item
An LRU cache that evicts with cache.erase(key) on a map<Key, Value*> leaks one object per eviction for the same reason. If you cannot change the element type (for example, the pointers are shared with a C API), keep one place in the code that is responsible for deleting them and route every removal through it.
shared_ptr cycles
Smart pointers do not make leaks impossible. Reference counting cannot reclaim a cycle:
#include <memory>
struct Node {
std::shared_ptr<Node> next;
std::shared_ptr<Node> prev; // both directions own: cycle
};
void circularReference() {
auto node1 = std::make_shared<Node>();
auto node2 = std::make_shared<Node>();
node1->next = node2;
node2->prev = node1;
} // locals go away, but each Node still has use_count 1
struct NodeFixed {
std::shared_ptr<NodeFixed> next;
std::weak_ptr<NodeFixed> prev; // observes without owning
};
When circularReference() returns, the local shared_ptrs go out of scope and each count drops by one. But node1->next still owns node2 and node2->prev still owns node1, so both counts stay at 1 even though nothing outside the pair can reach them. A weak_ptr does not contribute to the count; it can still check whether the target is alive with lock(), but the cycle’s counts now reach zero once the outside owners are gone. For any two-way relationship (parent and child, doubly linked list, observer and subject), make exactly one direction shared_ptr and the other weak_ptr, or use unique_ptr plus a raw non-owning back pointer when the lifetimes are strictly nested.
Because the objects in a cycle still point at each other when the program exits, leak checkers may classify some of the blocks as “indirectly lost” rather than “definitely lost”, which is easy to skim past. If a cycle hangs off a global or a long-lived object, nothing is reported at all until that owner is destroyed. When memory grows but the leak report looks quiet, a cycle is a good suspect.
Conditional returns
Every path that allocates must free—or use RAII/unique_ptr.
Exceptions between new and delete
Use smart pointers or vector so unwinding always calls destructors.
Examples and detection
Early-return leak + Valgrind
Compile with -g, run:
valgrind --leak-check=full --show-leak-kinds=all ./leak_early_return
Look for definitely lost and the stack trace.
delete vs delete[] + LeakSanitizer
g++ -fsanitize=address,leak -g -std=c++17 -o leak_array leak_array.cpp
./leak_array
Container of pointers
clear() without deleting each Item* → Valgrind reports many lost blocks.
Detection tools
Valgrind
g++ -g program.cpp -o program
valgrind --leak-check=full --show-leak-kinds=all ./program
Interpret definitely lost, Invalid read, etc.
AddressSanitizer (+ LeakSanitizer)
g++ -fsanitize=address,leak -g program.cpp -o program
./program
VS CRT debug heap (Windows)
_CrtSetDbgFlag / leak check on exit—see MSVC docs.
Debugging practice
- Confirm RSS grows over time (
top,ps) - Run Valgrind/ASan with representative workload
- Jump to reported line, fix ownership
- Re-run until clean
Common patterns
- Factory returning
T*→ returnunique_ptr<T> - Exception between
new/delete→ RAII mapof pointers →unique_ptrvalues or explicit delete on eraseshared_ptrcycles →weak_ptr
Unclear ownership
Many leaks are not a forgotten delete in one function but a disagreement between two functions about who was supposed to call it. A signature like Widget* makeWidget() or void consume(Widget* w) says nothing about whether the caller must delete the result or whether consume takes over the object. Both sides guess, and one of them guesses wrong: either nobody deletes (leak) or both do (double delete).
Smart pointer types in signatures make the rule visible to the compiler and the reader:
std::unique_ptr<Widget> makeWidget(); // caller owns the result
void consume(std::unique_ptr<Widget> w); // callee takes ownership (caller must std::move)
void inspect(const Widget& w); // borrows, never owns
void maybeNull(Widget* w); // non-owning, may be null
Following the C++ Core Guidelines convention, a raw T* in a modern codebase means “non-owning” by default. When wrapping an older C-style API that does return an owning raw pointer, convert it to a unique_ptr (with a custom deleter if the API has its own free function) at the boundary, so the ambiguity does not spread through the rest of the code.
Errors and fixes
- definitely lost: add matching
deleteor smart pointer invalid free/ double free: one owner, one delete- heap-use-after-free: lifetime bug—fix ordering, use
shared_ptr/weak_ptr - Mismatched
free/delete: pairnew↔delete,new[]↔delete[],malloc↔free
Best practices
| Principle | Bad | Good |
|---|---|---|
| Allocation | new T | make_unique<T>() |
| Arrays | new T[n] | vector<T> or make_unique<T[]>(n) |
| Ownership | return new T() | return make_unique<T>() |
| Containers | vector<T*> | vector<unique_ptr<T>> |
CI with ASan (-fsanitize=address,leak) on PRs is highly recommended.
Prevention: smart pointers
auto ptr = std::make_unique<int>(10); // instead of new int(10)
std::vector<int> vec(10); // instead of new int[10]
Every leaking example in this article breaks the same way: a raw new was paired with a delete that a person had to remember on every path. Each fix removes that requirement by handing the allocation to an object whose destructor already does the right thing. That makes the practical prevention rule short: do not call new directly in application code. std::vector, std::string, std::make_unique and std::make_shared cover almost every case, and a raw new/delete pair outside of a container or allocator implementation is worth flagging in code review.
unique_ptr fixes leaks, exception paths, and most double-delete issues on single ownership; shared_ptr covers genuinely shared lifetimes as long as cycles are broken with weak_ptr. The tools from section 5 are then the safety net that proves the rule held. See the next article for how each smart pointer works.
Related Articles
- Smart pointers
- Segmentation fault
- Interview: pointers vs references
- Stack vs heap — recursion crash
- C++ Memory Management: new/delete, Stack vs Heap, and RAII
- C++ error series: memory leak symptoms and causes
Closing
new/deleteare risky—prefer smart pointers and containers- Valgrind + ASan catch leaks and heap bugs
deletethennullptrhelps avoid double delete (raw pointers)- delete[] for arrays
- Exception safety: RAII Next: C++ practical guide #6-3: smart pointers
FAQ
When is this useful?
A. Whenever you manage heap memory in C++—servers, games, long-running tools. Use the examples and tool guide above.
What to read first?
A. Stack vs heap and the series index.
Go deeper?
A. cppreference memory, Valgrind and ASan docs.
Replace raw new/delete with smart pointers; use Valgrind and ASan to prove leaks are gone.