Pointers & Memory · intermediate · ~12 min

malloc and the heap

- Allocate a block of memory whose size is only known while the program runs - Check every allocation for failure and handle `NULL` safely - Compute allocation sizes correctly with `sizeof` and guard against integer overflow - Match every `malloc` with exactly one `free`, and reason about *ownership* - Recognize and prevent leaks, double-frees, and use-after-free bugs - Grow and shrink allocations with `realloc` without losing data

Overview

Up to now, most of your variables have lived on the stack: int n;, char buf[64];, function arguments. The compiler decides their size and lifetime for you. That is convenient, but it has a hard limit — the size has to be known when you compile, not when the program runs.

malloc breaks that limit. It lets your program ask for memory while it is running, in an amount you compute on the spot. You call malloc(n), it hands back a pointer to n fresh bytes on the heap, and those bytes are yours until you give them back with free.

This lesson builds directly on Pointer basics. Everything malloc returns is a pointer, and everything you learned about dereferencing (*p), pointer arithmetic (p + i, p[i]), and the special value NULL applies here. The one new idea is lifetime: a stack variable dies automatically when its function returns, but heap memory lives exactly as long as you decide — which means you are responsible for ending its life at the right time.

In plain language: the heap is a big pool of memory you borrow from by hand. malloc is "give me some," free is "I'm done with this." The terminology — dynamic allocation, heap, ownership — all describes that one borrow-and-return relationship.

Why it matters

Stack variables have a size fixed at compile time. That works until your program needs a buffer whose size is only known while it runs. Almost every interesting program hits that wall:

  • A text editor holds a document whose length you cannot predict.
  • A web server buffers a request body that could be 10 bytes or 10 megabytes.
  • A game keeps a list of enemies that grows and shrinks each frame.
  • A parser builds a tree whose shape depends entirely on the input file.

None of these can be a fixed-size stack array. Every dynamic data structure in C — resizable arrays, linked lists, hash tables, trees, graphs — is built on malloc and free. Learning to use them correctly is the difference between a program that runs for a second and one that runs for a month without leaking memory or crashing.

Core concepts

1. The heap vs. the stack

A C program's memory is divided into regions. Two of them matter here: the stack (automatic local variables) and the heap (memory you manage by hand).

High addresses
  +---------------------+
  |       stack         |  <- locals, grows downward, freed automatically
  |         |           |
  |         v           |
  |                     |
  |         ^           |
  |         |           |
  |        heap         |  <- malloc/free live here, grows upward
  +---------------------+
  |  globals / static   |
  +---------------------+
  |    code (text)      |
Low addresses
Property Stack Heap
Lifetime Ends when the function returns Ends when you call free
Size known At compile time At run time
Speed Very fast (bump a pointer) Slower (allocator bookkeeping)
Management Automatic Manual
Typical size Small (a few MB) Large (limited by RAM)

When to use the heap: when the size is unknown until runtime, when the data must outlive the function that created it, or when the object is too big for the stack. When NOT to: for small, short-lived, fixed-size data — a plain local variable is faster and cannot leak.

Pitfall: returning the address of a stack variable (return &local;) hands back memory that is already dead. Use the heap when data must survive the return.

Knowledge check: A function needs a 4-byte int counter that is used only inside that function. Stack or heap? Why?

2. What malloc actually returns

void *malloc(size_t n) returns a pointer to n bytes of uninitialized memory, or NULL if the request could not be satisfied. "Uninitialized" is critical: the bytes hold whatever was left there before. Reading them before you write is undefined behavior.

int *a = malloc(4 * sizeof(int));

 a ---> +------+------+------+------+
        | ???? | ???? | ???? | ???? |   heap, 16 bytes, garbage contents
        +------+------+------+------+
         a[0]   a[1]   a[2]   a[3]

The returned pointer is aligned for any object type, so you can safely store int, double, structs — anything — in that block. In C you do not cast the result; void * converts to any object pointer automatically.

When to use: any time you need a runtime-sized block. When NOT to: if you need the memory pre-zeroed, use calloc (the next lesson) instead of malloc + a manual clear.

Pitfall: assuming the memory starts at zero. It does not.

Knowledge check (predict the output): What is printed?

int *p = malloc(sizeof(int));
printf("%d\n", *p);

(Answer: undefined — *p was never written, so the value is garbage and the program has undefined behavior.)

3. Sizing the request correctly

The size argument is a byte count. To allocate space for n elements of type T, write malloc(n * sizeof(T)). A robust habit is to size against the destination pointer so the type can never drift out of sync:

int *a = malloc(n * sizeof *a);   // sizeof *a == sizeof(int), always correct

If you later change a to long *, this line still allocates the right amount — nothing to update.

Overflow warning: n * sizeof(T) is computed with size_t arithmetic. If n is huge (or attacker-controlled), the multiplication can wrap around to a small number, and you allocate far too little while thinking you got a big buffer. Validate n before multiplying, or use calloc(n, size), which checks the multiplication for you.

Pitfall: malloc(n) when you meant malloc(n * sizeof(int)) — allocates one quarter of the memory you needed on a 4-byte-int platform.

4. Ownership and free

Every successful malloc must be paired with exactly one free. The tricky part in real programs is deciding who is responsible when a pointer is passed around. That responsibility is called ownership.

malloc  ->  [ owned buffer ]  ->  free
  |                                 ^
  +--- exactly one owner frees it --+

A one-line comment settles it: // caller frees or // takes ownership. Ambiguous ownership is the root cause of both leaks (nobody freed it) and double-frees (two owners each freed it).

Bug Cause Symptom
Memory leak malloc with no free Memory grows the longer the program runs
Double free Two frees on one pointer Crash or heap corruption
Use-after-free Using a pointer after free Garbage reads, crashes, security holes
Wild/dangling ptr Freed pointer still held Same as use-after-free

When to free: as soon as the data is no longer needed, at the point where ownership ends. When NOT to: never free a pointer you did not get from malloc/calloc/realloc (e.g. a string literal or a stack address).

Knowledge check (find the bug):

char *s = malloc(16);
free(s);
strcpy(s, "hi");   // <-- what's wrong here?

(Answer: use-after-free — s was freed on the previous line, so writing through it is undefined behavior. Set s = NULL; after free to make the mistake crash loudly instead of silently corrupting.)

5. Resizing with realloc

void *realloc(void *p, size_t new_size) grows or shrinks an existing block. It may return the same address (extended in place) or a new address (the old data is copied over and the old block freed). Because the address can change, you must capture the return value — and into a temporary, so a failure does not lose your only pointer to the data.

int *tmp = realloc(a, new_n * sizeof *a);
if (!tmp) { /* a is still valid, handle error */ }
else a = tmp;

Pitfall: a = realloc(a, ...); — if realloc returns NULL, you have overwritten a and leaked the original block.

Syntax notes

#include <stdlib.h>   // malloc, free, realloc, calloc, size_t

int *a = malloc(n * sizeof *a);   // n elements; size taken from the pointer itself
if (a == NULL) {                  // ALWAYS check — malloc can fail
    /* out of memory: bail out cleanly */
}

a[0] = 42;                        // now safe to use

int *tmp = realloc(a, m * sizeof *a);  // resize; use a temporary
if (tmp) a = tmp;                      // only reassign on success

free(a);                          // return the memory — exactly once
a = NULL;                         // defuse the dangling pointer

Key points: include <stdlib.h>, do not cast the result in C, size with sizeof *pointer, check for NULL, and null out the pointer after freeing.

Lesson

void *malloc(size_t n) returns a pointer to n bytes of uninitialised memory on the heap, or NULL on failure. You must free it later, or you leak.

Three rules to remember every time:

  • Check the return value before using it.
  • Multiply correctly: use malloc(n * sizeof(T)).
  • Treat the contents as garbage until you write to them.

Code examples

#include <stdio.h>
#include <stdlib.h>

/* Build a heap array 0,1,...,n-1 and return it. Caller owns and frees it. */
int *make_range(int n) {
    if (n <= 0) return NULL;                 // reject nonsensical sizes
    int *a = malloc((size_t)n * sizeof *a);  // size from the pointer, cast n to size_t
    if (a == NULL) return NULL;              // propagate out-of-memory
    for (int i = 0; i < n; i++)
        a[i] = i;                            // fill the (previously garbage) memory
    return a;
}

int main(void) {
    int n = 5;
    int *nums = make_range(n);
    if (nums == NULL) {
        fprintf(stderr, "allocation failed\n");
        return 1;                            // fail cleanly, nothing to free
    }

    long sum = 0;
    for (int i = 0; i < n; i++) {
        printf("nums[%d] = %d\n", i, nums[i]);
        sum += nums[i];
    }
    printf("sum = %ld\n", sum);

    /* Grow the array to hold 3 more elements. */
    int new_n = n + 3;
    int *tmp = realloc(nums, (size_t)new_n * sizeof *nums);
    if (tmp == NULL) {
        fprintf(stderr, "resize failed\n");
        free(nums);                          // original still valid: free it
        return 1;
    }
    nums = tmp;                              // adopt the (possibly moved) block
    for (int i = n; i < new_n; i++)
        nums[i] = i * 10;                    // initialize the NEW slots only

    printf("after grow: nums[%d] = %d\n", new_n - 1, nums[new_n - 1]);

    free(nums);                              // release once
    nums = NULL;                             // avoid a dangling pointer
    return 0;
}

What it does: make_range allocates a runtime-sized array, fills it with 0..n-1, and returns ownership to main. main prints and sums the values, then uses realloc to enlarge the block, initializes only the new slots, and finally frees everything once.

Expected output:

nums[0] = 0
nums[1] = 1
nums[2] = 2
nums[3] = 3
nums[4] = 4
sum = 10
after grow: nums[7] = 70

Edge cases: make_range(0) or a negative n returns NULL (nothing allocated, nothing to free). If either malloc or realloc fails, the program prints an error and exits without leaking. Only the newly added slots after a grow are initialized; the original five keep their values because realloc preserves existing contents.

Line by line

Step Line What happens
1 if (n <= 0) return NULL; Guards against zero/negative sizes so the multiplication is meaningful.
2 malloc((size_t)n * sizeof *a) For n = 5, requests 5 * 4 = 20 bytes. Returns a pointer to 20 uninitialized bytes (garbage).
3 if (a == NULL) return NULL; If the OS refused, make_range reports failure upward instead of dereferencing NULL.
4 for ... a[i] = i; Writes real values into the garbage bytes: a[0]=0 ... a[4]=4. Now the memory is safe to read.
5 return a; Hands the pointer — and ownership — back to main. The heap block outlives the function.
6 int *nums = make_range(n); nums now points at the same 20-byte block.
7 if (nums == NULL) { ... return 1; } Caller re-checks; on failure it exits, and there is nothing to free.
8 print/sum loop Reads each element; sum becomes 0+1+2+3+4 = 10.
9 realloc(nums, 8 * sizeof *nums) Asks for 32 bytes. May extend in place or move the block, copying the first 20 bytes for you.
10 nums = tmp; Adopts the possibly-new address. The old nums value must not be used again.
11 for (i = 5; i < 8; ...) Initializes only slots 5,6,7 (the new ones). Slots 0–4 still hold 0–4.
12 free(nums); nums = NULL; Returns all 32 bytes once, then nulls the pointer so any later use crashes instead of corrupting.

After step 11 the block in memory looks like:

 index:  0  1  2  3  4   5    6    7
 value:  0  1  2  3  4   50   60   70
         \--- original ---/ \-- new slots --/

Common mistakes

Mistake 1 — Forgetting sizeof.

int *a = malloc(n);          // WRONG: n bytes, not n ints

This allocates a quarter of the memory you need (when int is 4 bytes). Writing a[5] then runs off the end. Fix:

int *a = malloc(n * sizeof *a);   // correct byte count

Recognize it: crashes or valgrind "invalid write" once your index passes the real size.

Mistake 2 — Not checking for NULL.

int *a = malloc(huge);
a[0] = 1;                    // WRONG: if malloc failed, this dereferences NULL

Fix: check immediately and bail out. Out-of-memory is rare but real, and a NULL dereference is an instant crash (or worse, exploitable).

Mistake 3 — Losing the pointer on realloc failure.

a = realloc(a, big);         // WRONG: NULL on failure overwrites and leaks a

Fix: use a temporary and only reassign on success.

int *tmp = realloc(a, big);
if (tmp) a = tmp; else { /* a still valid */ }

Mistake 4 — Use-after-free / double-free.

free(a);
a[0] = 1;   // WRONG: use-after-free
free(a);    // WRONG: double-free

Fix: free(a); a = NULL;. Freeing NULL is a harmless no-op, and any later dereference of NULL crashes loudly instead of silently corrupting the heap.

Mistake 5 — Casting the result in C.

int *a = (int *)malloc(...);  // unnecessary, and can hide a missing #include

In C, drop the cast. (In C++ the cast is required — but this is C.)

Debugging tips

Compiler errors:

  • implicit declaration of function 'malloc' — you forgot #include <stdlib.h>.
  • warning: comparison between pointer and integer — you compared to 0 where you meant NULL, or forgot the header so malloc was assumed to return int.

Runtime errors:

  • Segmentation fault right after malloc — you dereferenced a NULL return without checking, or wrote past the end because the size was wrong.
  • free(): invalid pointer / double free or corruption — you freed something twice, freed a non-heap pointer, or wrote past the block and clobbered the allocator's bookkeeping.

Logic errors:

  • Program slows down or grows the longer it runs → a leak: some malloc has no matching free.
  • Reading "random" values you never set → you read uninitialized malloc memory; write it (or use calloc) first.

Tools and steps:

  1. valgrind ./a.out — reports leaks, invalid reads/writes, and double-frees with line numbers. The gold standard.
  2. Compile with -fsanitize=address -g — faster, catches use-after-free and overflows at the moment they happen.
  3. Add -Wall -Wextra — catches the missing-header and cast warnings above.

Questions to ask when it breaks: Did I check the return value? Is my size count * sizeof(element)? Who owns this pointer, and did exactly one path free it? Am I reading memory before writing it?

Memory safety

malloc is where C's memory-safety burden becomes explicit. The concerns for this topic:

  • Initialization: malloc memory is garbage. Reading it before writing is undefined behavior. Write every byte you intend to read, or use calloc for zeroed memory.
  • Bounds: you allocated n elements; indices 0 .. n-1 are valid and nothing else. Off-by-one writes (a[n] = ...) corrupt adjacent heap data or the allocator's metadata — a classic heap-overflow bug.
  • Integer overflow in the size: count * sizeof(T) can wrap for large count, allocating a tiny buffer that you then overflow. Validate count, or use calloc(count, size), which detects the overflow and returns NULL.
  • Lifetime / ownership: the block lives until free. Freeing too early gives use-after-free; never freeing gives a leak; freeing twice corrupts the heap. Assign a single owner and document it.
  • Dangling pointers: after free(p), the value in p still points at reclaimed memory. Set p = NULL; so a stray dereference faults immediately instead of silently reading someone else's data.

Many real-world security vulnerabilities — heap overflows, use-after-free exploits — start with one of these mistakes. Defensive habits: check every return, size with sizeof *p, guard multiplications, free once, and null the pointer. Sanitizers (-fsanitize=address) and valgrind should be part of your normal build, not an afterthought.

Real-world uses

Concrete uses: Every resizable structure you rely on is built on malloc. C++'s std::vector, Python's list, the Linux kernel's dynamic buffers, SQLite's row storage, a JSON parser's node tree, a web server's per-connection request buffer — all allocate on the heap and grow with realloc. A game engine allocates per-frame object pools; a text editor allocates a gap buffer for the open document.

Professional best practices:

Beginner:

  • Always #include <stdlib.h> and check the return for NULL.
  • Size with sizeof *pointer, never a hard-coded number.
  • One malloc, one free; set the pointer to NULL afterward.
  • Run valgrind or ASan before you call the code done.

Advanced:

  • Make ownership explicit in the API — document "caller frees" or return a handle with a matching destructor function (thing_create / thing_destroy).
  • Prefer a temporary for realloc and validate size multiplications against overflow.
  • Batch small allocations (arena/pool allocators) to cut per-malloc overhead in hot paths.
  • Free in reverse order of acquisition, and centralize cleanup (a single goto cleanup: label) so every error path releases everything exactly once.

Practice tasks

1. (Beginner) Sum a runtime-sized array. Read an integer N from stdin, malloc an int array of size N, fill it with 1..N, print the sum, then free it. Requirements: check the malloc return; reject N <= 0. Example: input 4 → output sum = 10. Concepts: malloc, sizeof *p, NULL check, free.

2. (Beginner) Heap-copy a string. Given const char *src, allocate strlen(src) + 1 bytes, copy the string in with strcpy, print the copy, then free it. Hint: the + 1 is for the terminating '\0'. Concepts: sizing for strings, ownership.

3. (Intermediate) dup_array. Write int *dup_array(const int *a, int n) that returns a freshly malloc-ed independent copy of the first n elements. Prove independence by modifying the copy and showing the original is unchanged. Requirements: return NULL on bad n or allocation failure; caller frees. Concepts: allocation + copy loop, ownership documentation.

4. (Intermediate) Growable buffer. Implement void push(int **arr, size_t *len, size_t *cap, int value) that appends value, doubling capacity via realloc when *len == *cap. Start from cap = 0. Requirements: use a temporary for realloc; handle failure without leaking. Input/output: pushing 1..10 into an initially empty buffer yields a length-10 array [1..10]. Concepts: realloc, doubling growth, capacity vs. length.

5. (Challenge) 2D matrix on the heap. Allocate an r × c int matrix, fill m[i][j] = i * c + j, print it as a grid, then free every allocation with no leaks. Use either an array-of-row-pointers (int **) or a single flat block indexed as m[i * c + j] — implement both and note the trade-offs. Requirements: valgrind-clean; on any partial allocation failure, free what you already allocated before returning. Concepts: nested allocation, ownership of multiple blocks, ordered cleanup.

Summary

  • malloc(n) returns a pointer to n bytes of uninitialized heap memory, or NULL on failure. Always check the return.
  • Size correctly with count * sizeof *pointer, and watch for integer overflow on large counts (calloc guards this).
  • The heap gives you runtime-sized, long-lived memory the stack cannot. In exchange, you manage its lifetime.
  • Every malloc needs exactly one free. Decide and document ownership to avoid leaks and double-frees.
  • After freeing, set the pointer to NULL; treat fresh memory as garbage until you write it.
  • Use realloc via a temporary to resize without losing data, and run valgrind / AddressSanitizer to catch mistakes.
  • Remember: no cast in C, include <stdlib.h>, and free once. With these habits the heap is a reliable tool, not a trap.

Practice with these exercises