C Memory Leak — 30 Bytes/Req Crashed at 12h
A 30-byte C leak at 1000 req/s OOM'd a payment gateway in 12 hours.
20+ years shipping performance-critical C and C++ systems. Everything here is grounded in real deployments.
- malloc allocates uninitialised memory; calloc zeroes it and checks overflow
- realloc grows or shrinks existing allocations; always use a temp pointer
- free returns memory to the heap; set pointer to NULL after to prevent use-after-free
- Stack is fixed size (~1-8 MB); heap scales to available RAM
- Every allocation must have a matching free — leaks crash long-running processes
- Valgrind and AddressSanitizer catch leaks, double-frees, and buffer overflows
Imagine you're planning a dinner party but you don't know how many guests are coming. You could set up 1,000 chairs just in case — wasteful — or you could wait until people RSVP and then grab exactly the right number of chairs from a storage room. That storage room is the heap, and malloc/calloc are how you request chairs. When the party's over, you return the chairs with free so someone else can use them. That's dynamic memory management in one paragraph.
Every real-world C program — from embedded firmware in your car to the Linux kernel — has one thing in common: they can't always know at compile time how much memory they'll need. A web server doesn't know how many simultaneous connections it'll handle. A text editor doesn't know how long the document will be. If you hardcode array sizes, you either waste memory or crash when input exceeds your guess. This is the gap dynamic memory management was built to fill.
C gives you direct control over the heap — a large pool of memory your program can borrow from at runtime. The standard library functions malloc, calloc, realloc, and free are your tools for doing that. Unlike stack memory, which is automatically managed as functions are called and return, heap memory lives until YOU explicitly release it. That power is also the responsibility: C won't clean up after you.
By the end of this article you'll understand not just the syntax of these four functions, but WHY each one exists, when to choose one over another, how to detect and prevent memory leaks, and what interviewers are really testing when they ask about dynamic allocation. You'll have runnable code patterns you can drop straight into real projects.
Why Manual Memory Management in C Is a Double-Edged Sword
Memory management in C is the explicit allocation and deallocation of heap memory via malloc(), calloc(), realloc(), and free(). Unlike garbage-collected languages, C gives you full control — and full responsibility. Every malloc() returns a pointer to a block of memory; every successful allocation must eventually be matched with a free(). Fail to free, and you leak memory. Free too early, and you get a dangling pointer. Free twice, and you corrupt the heap. The core mechanic is simple: you own every byte you allocate, and the runtime will not clean up after you.
In practice, C's memory model is flat and contiguous. malloc() returns a void* to a block from the heap, typically managed by the OS via brk() or mmap(). The allocator tracks free chunks; fragmentation grows over time if you allocate and free irregularly. There is no bounds checking — writing past the end of an allocated buffer corrupts adjacent memory silently. This is why tools like Valgrind and AddressSanitizer exist: they catch what the language itself does not enforce.
Use manual memory management when you need predictable performance, minimal overhead, or are working on embedded systems, kernels, or real-time applications. It matters because a single leak in a long-running server can accumulate into a crash — as in the 30 bytes/request leak that brings down a service after 12 hours. Understanding C's memory model is not optional if you write systems software; it is the difference between a reliable daemon and a ticking time bomb.
malloc() creates a debt. If you don't track who frees it, you will either leak or double-free — there is no third option.free() in a rarely-taken error path. After 12 hours at 10k req/s, the process RSS hit 12 GB and OOM killer terminated it. Rule: always free in the same function scope that allocated, or use a clear ownership transfer contract.malloc() must have a matching free() — no exceptions.Why the Stack Isn't Enough — The Case for Heap Allocation
When you declare int scores[100]; inside a function, it lives on the stack. The stack is fast, automatically managed, and perfect for small, fixed-size data. But it has two brutal limitations: its size is fixed (typically 1–8 MB), and every variable must have a known size at compile time.
Think about reading a CSV file with an unknown number of rows, or building a linked list that grows as users add items. You can't express that with stack arrays. You need memory you can size at runtime and control the lifetime of explicitly.
The heap is that place. It's a large region of memory (limited by your RAM and OS) that your program can request chunks from dynamically. The heap doesn't auto-clean — a chunk stays allocated until your code calls free on it. This is where malloc and its siblings come in: they're the formal interface between your program and the heap allocator.
One more thing: stack memory dies when the function returns. If you need data to outlive the function that created it — like returning a dynamically built string to a caller — the heap is the only option.
int buffer[1000000]; on the stack will silently corrupt your program or crash it — the stack is typically only 1-8 MB. Any buffer whose size depends on runtime input or exceeds a few kilobytes belongs on the heap.Stack vs Heap — Quick Comparison Table
When deciding where to place your data, it helps to see the key characteristics side by side. This table distills the most important differences between stack memory and heap memory in C.
| Feature | Stack | Heap |
|---|---|---|
| Allocation/Deallocation | Automatic — done by the compiler when functions are called and return | Explicit — you must call malloc/calloc/realloc and free |
| Size | Fixed at program start (typically 1–8 MB per thread) | Limited by available RAM (can grow to gigabytes) |
| Speed | Fast — simple pointer manipulation | Slower — requires system call overhead and allocation bookkeeping |
| Lifetime | Tied to function scope — dies when the function exits | Lives until explicitly freed, or until program exit |
| Flexibility | Size must be known at compile time (or use C99 VLAs, which still live on stack) | Size can be determined at runtime (user input, file contents, etc.) |
| Data persistence | Cannot return a pointer to local data — it's invalid after function returns | Pointers to heap data remain valid as long as you don't free them |
| Fragmentation | None — stack is contiguous | External fragmentation possible — allocator may waste space |
| Thread safety | Each thread has its own stack — inherently thread-safe | Heap is shared — allocations need synchronization (mutex) in multithreaded code |
| Risk | Stack overflow (silent corruption or crash) | Memory leaks, double-free, use-after-free, out-of-memory |
When to use each: - Use the stack for small, fixed-size local variables, function parameters, and anything that doesn't need to outlive its function. It's fast, safe, and automatic. - Use the heap for large buffers, data structures that grow dynamically (linked lists, dynamic arrays, trees), and any data that must survive the function that created it.
The code below shows a concrete example of each allocation type and how to handle them correctly.
mallinfo() or malloc_stats().malloc vs calloc — Same Job, Different Guarantee
Both malloc and calloc allocate heap memory, but they differ in one critical way: calloc zeroes out the memory it allocates, while malloc leaves whatever garbage was there before.
malloc(n) takes a single argument — the number of bytes to allocate. It's the faster choice when you're about to overwrite every byte anyway (like reading from a file or socket into a buffer). The garbage contents don't matter because you'll replace them immediately.
calloc(count, size) takes two arguments — the number of elements and the size of each. It's designed specifically for arrays. Beyond the zero-initialization, it also handles the multiplication safely: if count * size would overflow a size_t, calloc can detect it. malloc with manual multiplication cannot.
Choose calloc when you need a guaranteed-zero starting state — like a boolean flags array, a counter array, or any structure where zero is a valid 'empty' sentinel. Choose malloc when you're going to initialize the memory yourself immediately, and you want the marginal performance gain of skipping the zero-fill.
Neither function initialises memory to any particular value beyond calloc's zero guarantee — use memset if you need a non-zero fill after malloc.
Memory Function Signatures Reference Table
Every C programmer should have the exact prototypes of the four dynamic memory functions committed to muscle memory. Here they are, with their required header and a quick explanation of each argument.
| Function | Prototype | Header | Description |
|---|---|---|---|
malloc | void *malloc(size_t size); | <stdlib.h> | Allocates size bytes of uninitialised memory. Returns pointer to allocated memory, or NULL on failure. |
calloc | void *calloc(size_t nmemb, size_t size); | <stdlib.h> | Allocates memory for an array of nmemb elements, each of size bytes. All bytes are zero-initialised. Returns pointer or NULL. Also checks for integer overflow in nmemb * size. |
realloc | void realloc(void ptr, size_t new_size); | <stdlib.h> | Changes the size of the memory block pointed to by ptr to new_size bytes. The contents up to the minimum of old and new size are preserved. Returns pointer to new block (may differ from old) or NULL on failure. If ptr is NULL, behaves like malloc. If new_size is 0, behaviour is implementation-defined (commonly like free). |
free | void free(void *ptr); | <stdlib.h> | Deallocates the memory block pointed to by ptr that was previously allocated by malloc, calloc, or realloc. If ptr is NULL, no operation is performed. |
Important details to remember: - All functions return void *, which means they can be assigned to any pointer type without an explicit cast in C (unlike C++). However, always assign to a pointer of the correct type after checking for NULL. - The size_t type is an unsigned integer defined in <stddef.h>. Guaranteeing that sizes fit in size_t is your responsibility — calloc's overflow check is a safety net, not a guarantee of success. - realloc(ptr, 0) is implementation-defined: it may free the block and return NULL, or it may return a unique pointer (C standard allows both). Avoid relying on this behaviour — use free(ptr) explicitly. - free(NULL) is safe and does nothing. This is why setting pointers to NULL after free is so effective: later calls to free become harmless.
The code below shows a complete example that uses all four signatures correctly, including error handling.
malloc with dlsym(RTLD_NEXT, "malloc") to log every allocation — but you must match the exact prototype to avoid ABI mismatch.realloc and free — Growing Memory and Giving It Back
Once you've allocated memory, real programs often need to grow it. A dynamic array that starts at 10 elements might need 10,000 by the time the user is done. That's realloc's job: resize an existing allocation without you having to malloc a new block, memcpy the old data, and free the old pointer manually.
realloc takes your existing pointer and a new size. It tries to extend the block in place; if it can't, it allocates a new block, copies your data, and frees the old one automatically. The key danger: realloc returns a new pointer (which may differ from the old one), so you must assign the return value to a pointer variable. If you ignore it and keep using the old pointer, you're in undefined behaviour territory.
The classic growth pattern is to double capacity each time you run out — this gives amortised O(1) appends and is exactly how C++ std::vector works under the hood.
free is straightforward but has strict rules: only free pointers returned by malloc/calloc/realloc, never free the same pointer twice, and never use a pointer after freeing it. After calling free, immediately set the pointer to NULL — it costs nothing and prevents a whole class of bugs called use-after-free.
ptr = realloc(ptr, new_size) directly. If realloc fails it returns NULL and your original pointer is overwritten — you've now lost the only reference to your allocated memory, creating an instant leak. Always assign to a temporary pointer, check for NULL, then update the original.Detecting and Preventing Memory Leaks — Real Tools, Real Habits
A memory leak happens when you allocate heap memory and never free it. The program keeps running, keeps allocating, and eventually either runs out of memory or gets killed by the OS. In long-running processes — servers, daemons, embedded systems — even a tiny leak per request will eventually bring the system down.
The most effective tool for catching leaks is Valgrind (Linux/macOS). Run your program with valgrind --leak-check=full ./your_program and it'll report every byte that was allocated but not freed, including the exact line where the allocation happened. On Windows, use AddressSanitizer (built into MSVC and Clang with -fsanitize=address).
Beyond tools, the best defence is clean ownership patterns: every allocation should have a clear owner (the code responsible for freeing it), allocations and their matching frees should live in the same layer of abstraction (don't malloc in one module and free in another without a documented contract), and every pointer should be set to NULL after free.
The code below demonstrates a real leak, what Valgrind reports, and the fix.
-fsanitize=address -g to your GCC/Clang compile flags during development. It catches leaks, use-after-free, and buffer overflows at runtime with almost zero setup — faster than Valgrind and no separate tool to install. Remove it for production builds.Common Mistakes with Dynamic Memory and How to Debug Them
Even experienced C programmers make the same three mistakes: not checking malloc's return, double-freeing, and use-after-free. Each has a distinct symptom and a straightforward fix once you know what to look for.
Not checking malloc's return: If malloc returns NULL, dereferencing it is undefined behaviour — your program crashes with a segfault, but not consistently, because NULL may map to a valid address on some systems. Always check if (ptr == NULL) after every allocation.
Double-freeing: Calling free twice on the same pointer corrupts the heap's internal bookkeeping. The next malloc/calloc/realloc may crash, or data may be silently corrupted. The fix: set pointer to NULL after free, and guard with if (ptr) before calling free.
Use-after-free: Accessing memory after calling free on it. The memory may have been reallocated to another part of the program, so reading it gives garbage or writes corrupt other data. This is hard to reproduce. AddressSanitizer catches it deterministically.
Here's a code example showing all three mistakes and how to write them correctly.
The Cost of Allocation — Why Every malloc Is a System Call Gamble
Memory allocation in C isn't free. Every call to malloc or calloc is a system call that requests memory from the kernel. That call costs CPU cycles, context switches, and cache misses. In hot code paths—like game loops, network request handlers, or real-time audio buffers—a single malloc can tank your latency budget.
For example, a classic production pipeline for a video game spawns enemy objects each frame. Calling malloc per enemy is a rookie mistake. The kernel has to service the request via mmap or sbrk, which may zero memory or acquire a mutex in the heap's free list. That's microseconds you don't have per frame. Java and Python hide this with garbage collection pauses; C gives you the control to avoid it entirely.
Why does this matter? Because the alternative is memory pools—pre-allocate a slab of memory once and carve it into fixed-size chunks. That turns allocation into a simple pointer increment. No syscall, no lock, no surprise. You want speed and determinism? That's how you get it.
malloc inside a hot loop can cause thread contention and heap fragmentation. Profile with perf or Valgrind before assuming it's fast. Memory pools are your friend.Alignment — The Hidden Landmine That Corrupts Your Data
Every type has an alignment requirement. An int wants a 4-byte boundary; a double wants an 8-byte boundary; a 16-byte SSE vector wants 16. When you allocate with malloc, it guarantees the pointer is aligned for the largest scalar type on your platform (usually 8 or 16 bytes). But custom allocators often skip this guarantee.
How does this bite you? Say you implement a slab allocator that returns offsets from a char array. On x86, unaligned access might work but cost 2x to 3x the CPU cycles. On ARM, it triggers a trap and your program crashes. Now your production server reboots every hour. Congrats, you've just learned why posix_memalign exists.
Real-world fix: align your pools to at least 16 bytes. Use alignas(16) in C++ or __attribute__((aligned(16))) in C. Check the pointer value after every custom alloc: if ((uintptr_t)ptr % 16 != 0) . That abort();abort will surface in your crash logs faster than silent data corruption.
((uintptr_t)ptr % alignof(max_align_t) == 0) inside an assertion. Ship a debug build with that—it will catch custom allocator bugs the minute they happen.Stop Using malloc/free in C++ — Here's Why
You're writing C++. Stop pretending it's C with classes. malloc and free have no business in modern C++ code. They don't call constructors or destructors. That means your objects start life uninitialized and die without cleanup. Leaked resources, dangling pointers, undefined behavior — the whole mess.
malloc returns void* that you manually cast. One wrong cast and you're corrupting memory. free doesn't run destructors — your std::vector never releases its heap buffer, your mutex stays locked. Production systems crash on this daily.
Use new/delete for single objects, new[]/delete[] for arrays. Better yet, skip all of it. std::make_unique and std::make_shared allocate and construct in one shot. No casts, no leaks, no forgotten destructors. The compiler enforces correctness. Your future self — and your on-call rotation — will thank you.
new/delete Correctness — One Rule That Saves You
Here's the only rule you need: match new with delete, new[] with delete[]. Mix them and you get undefined behavior — corrupted heap metadata, crashes at free, silent memory corruption that surfaces three sprints later.
new[] allocates extra bytes to store the array count. delete[] reads that count to run destructors on every element. delete just frees the block without calling destructors. That's a leak. Worse, delete[] on a single object reads garbage as the count — it will try to destroy 100 million random objects before crashing.
The fix is trivial — never use new or new[] directly. Use std::vector for arrays. Use std::make_unique for single objects. Raw new/delete belong in legacy code and interview questions. Production code deserves RAII wrappers that make mismatched deletes impossible at compile time.
Placement new — Constructing Objects on Pre-Allocated Memory
Placement new is not an allocator. It constructs an object at a specific memory address you already own, bypassing heap allocation entirely. This is critical when you need deterministic construction in shared memory, memory-mapped I/O, or custom pools. The syntax is new (address) Type(args). You must manually call the destructor to clean up, never use delete on a placement new address because delete also calls operator delete which frees memory you didn't allocate from the heap. Placement new separates memory allocation from object construction. Use it when allocation overhead is unacceptable or memory is externally managed. The hidden cost is increased manual lifetime control. You must know exactly when to call destructors, or you leak resources inside the object. It's powerful, but one wrong destructor call corrupts your entire memory pool. Reserve placement new for performance-critical systems where every allocation counts.
Mixing new/delete with malloc()/free() — Undefined Behavior Waiting to Happen
You cannot mix malloc/free with new/delete on the same memory block. It's undefined behavior. malloc allocates raw bytes, new allocates memory and constructs objects. free releases raw memory with no destructor call, delete calls destructors then releases memory. If you malloc then delete, the object's destructor runs on memory that was never constructed, corrupting internal state. If you new then free, the destructor never runs, leaking resources inside the object. Both patterns crash or silently corrupt data. Even new and delete[] must match arrays correctly — use delete[] for array allocations. The rule is simple: pair malloc with free, new with delete, new[] with delete[]. Cross-pairing breaks the C++ object model and memory manager assumptions. Production code reviews catch this immediately. Treat any mix as an instant bug.
C++ new Expression — How It Differs from malloc
The new expression in C++ is not a function call but a language-level operator that integrates memory allocation with object construction. Unlike malloc, which only allocates raw bytes, new calls the constructor of the class immediately after allocating memory. This guarantees that objects are initialized to a valid state before use. Internally, new invokes operator new (which may call malloc) and then the constructor. If the constructor throws, new automatically deallocates the memory, preventing leaks. The array form new[] also tracks the number of elements to call the destructor for each one. This is fundamentally safer than malloc because it ensures construction and destruction happen correctly. When you write auto p = new , you get a fully initialized object. With MyClass()malloc, you would need placement new to achieve the same, inviting mistakes. Always prefer new over malloc in C++ code to leverage RAII and exception safety from the start.
new with free() or malloc with delete. Either pair results in undefined behavior because the memory allocator metadata differs. Stick to new/delete in C++.new in C++ to guarantee construction and automatic cleanup—never fall back to malloc.Practical Examples — Real-World Memory Management Patterns
Consider a video processing pipeline that reads frames and applies filters. Using raw new/delete leads to manual cleanup and leaks when exceptions occur. A safer pattern uses RAII wrappers like std::vector for contiguous buffers and std::unique_ptr for single objects. For example, a FilterPipeline class stores a heap-allocated kernel via std::unique_ptr<Kernel> and a frame buffer via std::vector<uint8_t>. When the pipeline goes out of scope, destructors automatically free the memory. Another pattern is custom memory pools: for thousands of small allocations (e.g., particle systems), pre-allocate a large block via operator new[] and hand out slices with placement new. This avoids repeated system calls and fragmentation. Always prefer standard containers and smart pointers over raw pointers unless performance profiling proves a bottleneck. Debug builds with sanitizers (ASan, UBSan) catch mismatches early. Write unit tests that exercise allocation-heavy paths to verify no leaks with tools like Valgrind.
new. Instead, pool memory in a vector or use a custom allocator to reduce fragmentation and performance spikes.The Silent Leak That Took Down a Payment Gateway
strdup() was used to copy request data, but the caller never freed the duplicated string. Each request leaked exactly the length of the transaction ID plus overhead (average 30 bytes). At ~1000 requests/second, that's 30 KB/s — 1.8 MB/min — 108 MB/hour — crash at 3.8 GB after ~35 hours. The crash happened at 12 hours because of concurrent threads and fragmentation.free() after each use of the duplicated string. Introduced a code review checklist: every malloc/strdup must have a corresponding free identified within the same function or clearly documented ownership.- Small leaks kill: 30 bytes per request × 10 million requests = 300 MB.
- Ownership must be explicit: if function A returns a heap pointer, document that caller must free.
- Monitor memory growth trends, not just absolute usage — a linear climb is a reliable leak indicator.
- Valgrind and AddressSanitizer would have caught this in the first test run.
-fsanitize=address -g). If not available, add assert(ptr != NULL) before dereference.valgrind --tool=memcheck). Look for 'Invalid free' or 'double free' in output. Fix by ensuring each free is called exactly once — set pointer to NULL after free.--leak-check=full to identify allocation sites without matching free. In production, use mtrace or library interposition to log allocations. Consider heap profiling with perf.-lmcheck on Linux or MALLOC_CHECK_=3 environment variable. Run under AddressSanitizer to catch buffer overflows.ptr = realloc(ptr, new_size). Replace with temporary pointer pattern. Always preserve original until realloc succeeds.gcc -fsanitize=address -g -o program program.c && ./programFor detailed leak summary at exit: export ASAN_OPTIONS=detect_leaks=1Key takeaways
malloc(count * size) when count is large.Common mistakes to avoid
4 patternsNot checking malloc/calloc/realloc return value
if (ptr == NULL) immediately after every allocation. Print an error message and handle gracefully (return error, clean up any already-allocated resources, exit if unrecoverable).Directly assigning realloc return to original pointer
void tmp = realloc(ptr, new_size); if (tmp != NULL) { ptr = tmp; } else { / handle error */ }Double-free or use-after-free
ptr = NULL after every free(ptr). This makes a second free safe (free(NULL) is a no-op) and prevents accidental use of the stale pointer.Shrinking allocation with realloc and losing data
Interview Questions on This Topic
What is the difference between malloc and calloc, and when would you choose one over the other?
Frequently Asked Questions
20+ years shipping performance-critical C and C++ systems. Everything here is grounded in real deployments.
That's C Basics. Mark it forged?
16 min read · try the examples if you haven't