Programming

malloc vs new in C++, and Finding the Leak

What malloc and new actually do differently, why the pairs must never cross, and how AddressSanitizer and Valgrind point at the exact line that leaked.

5 min readUpdated Tuesday, Oct 13, 2026
cppmemory-managementdebugging

A shallow stream running over moss-covered boulders in dappled forest sunlight

The short version: malloc hands you bytes, and new hands you an object. Most memory bugs in C++ come from forgetting which one you’re holding, or from forgetting you’re holding anything at all.

This note covers what each one does, the one pairing rule that matters, and how to make the compiler’s tools find the leak for you.

Bytes versus objects

Widget* a = static_cast<Widget*>(std::malloc(sizeof(Widget)));  // raw bytes
Widget* b = new Widget();                                        // a constructed Widget

malloc returns uninitialized memory, aligned for any scalar type. It runs no constructor, and it reports failure by returning a null pointer you have to check.

new does two steps. It calls operator new to get memory, then runs the constructor in it. If the allocation fails, the default operator new throws std::bad_alloc instead of returning null. delete reverses both steps: destructor first, then deallocation.

So a points to something shaped like a Widget that has never been a Widget. For simple types (C++20 calls them implicit-lifetime types) that’s fine. For anything with a constructor that sets things up, it isn’t.

The pairs never cross

malloc  → free
new     → delete
new[]   → delete[]

Crossing them is undefined behavior, even when it seems to work. The array pair is the one that bites. For element types with a destructor, the common C++ ABI used by GCC and Clang stores the element count in a small header just before the array, so delete[] knows how many destructors to run. Plain delete doesn’t know that header exists.

A small program with two leaks

#include <cstdlib>
#include <cstring>

struct Session {
    char* buffer;
    explicit Session(std::size_t n) : buffer(new char[n]) {}
    ~Session() { delete[] buffer; }
};

char* read_name() {
    char* name = static_cast<char*>(std::malloc(32));
    std::strcpy(name, "worker-1");
    return name;  // the caller now owns this and must free() it
}

int main() {
    Session* session = new Session(256);  // never deleted
    char* name = read_name();             // never freed
    (void)session;
    (void)name;
    return 0;
}

It compiles cleanly, runs, and exits 0. Nothing complains.

AddressSanitizer: rebuild, run, read

Add -fsanitize=address to both compile and link (one g++ command does both):

g++ -fsanitize=address -g -O0 leak.cpp -o leak
./leak

Output from GCC 14.4 on Linux, trimmed to the useful frames:

==14==ERROR: LeakSanitizer: detected memory leaks

Direct leak of 32 byte(s) in 1 object(s) allocated from:
    #0 0xffff82643f08 in malloc (/usr/local/lib64/libasan.so.8+0xd3f08)
    #1 0x400b50 in read_name() /src/leak.cpp:11
    #2 0x400bc4 in main /src/leak.cpp:18

Direct leak of 8 byte(s) in 1 object(s) allocated from:
    #0 0xffff82644a5c in operator new(unsigned long) (/usr/local/lib64/libasan.so.8+0xd4a5c)
    #1 0x400b8c in main /src/leak.cpp:17

Indirect leak of 256 byte(s) in 1 object(s) allocated from:
    #0 0xffff82644b9c in operator new[](unsigned long) (/usr/local/lib64/libasan.so.8+0xd4b9c)
    #1 0x400c6c in Session::Session(unsigned long) /src/leak.cpp:6
    #2 0x400ba0 in main /src/leak.cpp:17

SUMMARY: AddressSanitizer: 296 byte(s) leaked in 3 allocation(s).

Each trace points to where the memory was allocated, not where you forgot to free it. Finding the missing free is still your job, but now you know which pointer to follow.

  • Direct leak: nothing points to the block any more (the 32-byte name, the 8-byte Session object).
  • Indirect leak: the block is only reachable through another leaked block. The 256-byte buffer lives inside the leaked Session, so fix the direct leak and this one goes with it.

The process exits with status 1, which makes it easy to fail a CI job on. Leak checking is on by default on Linux. On macOS the Clang docs say to enable it with ASAN_OPTIONS=detect_leaks=1. For everyday use, the same docs suggest -O1 and -fno-omit-frame-pointer for speed and cleaner stack traces. ASan’s typical slowdown is about 2x.

Valgrind: no rebuild, much slower

If you can’t rebuild with sanitizers, Memcheck works on a normal debug build:

g++ -g -O0 leak.cpp -o leak
valgrind --leak-check=full ./leak
==195== 32 bytes in 1 blocks are definitely lost in loss record 1 of 3
==195==    at 0x488545C: malloc (vg_replace_malloc.c:446)
==195==    by 0x400893: read_name() (leak.cpp:11)
==195==    by 0x400913: main (leak.cpp:18)
==195==
==195== 264 (8 direct, 256 indirect) bytes in 1 blocks are definitely lost in loss record 3 of 3
==195==    at 0x4885C0C: operator new(unsigned long) (vg_replace_malloc.c:487)
==195==    by 0x4008DB: main (leak.cpp:17)
==195==
==195== LEAK SUMMARY:
==195==    definitely lost: 40 bytes in 2 blocks
==195==    indirectly lost: 256 bytes in 1 blocks

Same story, different accent. Valgrind folds the indirect buffer into the Session record (8 direct, 256 indirect).

Two things to know. Memcheck runs roughly 10 to 50 times slower than native. And by default Valgrind exits with the program’s own exit code, here 0, even after reporting leaks. Add --error-exitcode=1 if a script should notice.

The mismatched delete, caught

int main() {
    int* ids = new int[8];
    delete ids;  // should be delete[] ids;
    return 0;
}

Built with -fsanitize=address, this stops at once:

==21==ERROR: AddressSanitizer: alloc-dealloc-mismatch (operator new [] vs operator delete) on 0x503000000040
    #0 0xffff9589577c in operator delete(void*, unsigned long) (/usr/local/lib64/libasan.so.8+0xd577c)
    #1 0x400828 in main /src/mismatch.cpp:3

The fix is to stop owning things by hand

#include <memory>
#include <string>
#include <vector>

struct Session {
    std::vector<char> buffer;
    explicit Session(std::size_t n) : buffer(n) {}
};

std::string read_name() {
    return "worker-1";
}

int main() {
    auto session = std::make_unique<Session>(256);
    std::string name = read_name();
    return 0;
}

Same program, no new, delete, malloc, or free. Under AddressSanitizer it prints nothing and exits 0. Containers and smart pointers free memory when they go out of scope, which is the technique C++ calls RAII. That’s the whole trick: there’s nothing left to forget.

Reach for raw malloc when a C API demands it, and new mostly inside a smart pointer factory like std::make_unique. Anywhere else, a raw owner is a leak report waiting for its turn.

If you see this

  • Direct leak … allocated from your function: that pointer was dropped without being freed. Follow it from that line.
  • Only Indirect leak lines left: something that owns them is still leaking. Look for the direct one.
  • alloc-dealloc-mismatch: a new[] met a delete, or malloc met delete. Match the pair.
  • Valgrind reports leaks but CI stays green: add --error-exitcode=1.

References

Cover photo: Unsplash.