HomeAbout BlogsProjects Moisture Meter Teaching Roles Workshops Talks Contact
Pointers: Thinking in Memory

Stack, Heap, and Lifetime

Every pointer points at something. That something has to exist when you read through the pointer. Two regions of memory answer the question of where things live and how long they stick around, and the difference between them is where most pointer bugs are born.

Why this post is the one that bites

So far we've talked about pointers as if the thing they point at is always just there. We wrote int x = 42, we took &x, we dereferenced through a pointer, and everything worked. Fine. But we never asked a question that every non-trivial program has to answer: where is that x actually living, and when does it stop existing?

That question matters because a pointer is a wire to a location in memory. If the location is still there when you dereference, you get the value you expect. If the location is gone, you get whatever the operating system decides to do to you that day, which is usually either a crash or silent corruption. "Location is gone" is more common than most people realise, and it's the single biggest source of pointer bugs in real code.

To reason about this you need to know about the two regions where values live in a running C program: the stack and the heap.

A pointer is only as safe as the thing it points at. If that thing has gone away, the pointer is a lie.

Two regions, two philosophies

When your program runs, the operating system hands it a chunk of memory to work with. Inside that chunk, your program organises things into sections. The two that matter for us are the stack and the heap. They're just memory. The distinction is about how memory gets allocated and freed, not about what's physically different at the chip level.

The stack is managed automatically. Every time you call a function, the program pushes a new stack frame with space for that function's local variables and parameters. When the function returns, the frame is popped and those variables are gone. You don't write any code to allocate or free. The compiler emits the instructions, the CPU does the work, it's fast, and it's predictable.

The heap is manual. If you want memory that lives beyond a single function call, or memory whose size you don't know until runtime, you ask the operating system for it with malloc (in C) or new (in C++). You get back a pointer. The memory stays yours until you release it with free or delete. If you forget, it sits there until your program exits, consuming memory you'll never use again. If you release it twice, or use it after releasing it, you've invited undefined behaviour into the building.

Stack memory is managed for you. Heap memory is your responsibility.

A rough summary of the trade-offs:

  • Stack. Fast. Small (usually 1 to 8 MB per thread). Automatic lifetime, tied to function calls. Size must be known at compile time in standard C.
  • Heap. Slower. Big (limited by system memory, often gigabytes). Manual lifetime, you allocate and free. Size can be decided at runtime.

When you hear experienced C programmers say "put it on the stack when you can," they mean it. Stack allocation is essentially free. Heap allocation involves bookkeeping, potential system calls, and fragmentation over time. But the stack has limits. It's small and tied to function calls, so anything big or anything that has to live past a return has to go on the heap.

Why is the stack so small?

A 1 to 8 MB stack sounds tiny next to a heap that can be gigabytes. The asymmetry is deliberate, not an oversight. Two concrete reasons.

First, every thread gets its own stack. If stacks were large by default, a server program with 1000 concurrent threads would reserve 8 GB of address space for stacks before the program did anything useful. That's not a theoretical concern. Real servers hit this. Keeping stacks small is what makes heavy threading affordable.

Second, the stack has to be contiguous in virtual memory. Unlike the heap, which can scatter allocations across the address space wherever there's a free block, a stack is one growing region. Reserving a huge contiguous chunk per thread, even if most of it sits unused, wastes address space the OS could use for something else.

There's also a useful third effect: the smallness forces discipline. When you try to do something like this, it crashes before the function even runs properly:

void bad(void) {
    int huge[2000000];  // about 8 MB. blows the whole stack.
    // stack overflow, segfault, no useful error message
}

The right fix is obvious once you see it: big things go on the heap.

void good(void) {
    int* huge = malloc(2000000 * sizeof(int));
    // ... use huge ...
    free(huge);
}

The practical rule of thumb: anything larger than a few KB, or anything whose size depends on user input, should go on the heap. Stack is for small, short-lived things. This is also why deep recursion is dangerous in C. Each call pushes a frame, and enough recursion blows the stack the same way a huge array would. Production code often avoids deep recursion for exactly this reason, or explicitly checks recursion depth before making the next call.

The stack is small on purpose. It forces you to put big things on the heap and to keep recursion under control.

And as for cleanup when the program exits: yes, the OS reclaims everything. Stack, heap, leaked allocations, all of it goes back to the pool when the process ends. You will not permanently consume RAM by forgetting to free. The reason we still treat leaks as bugs is that "program ends" might be "in three months, after the memory footprint has grown 200 GB and the OS killed your server."


How the stack actually works

Think of the stack as a pile of trays in a cafeteria. Each function call slides a tray on top. When the function returns, the tray gets yanked off. The tray holds that function's local variables and arguments. Everything below it belongs to the function that called this one, and below that the function that called that one, all the way down to main.

void inner(int a) {
    int y = a * 2;  // y lives in inner's frame
}

void outer(void) {
    int x = 10;   // x lives in outer's frame
    inner(x);        // inner's frame is pushed
}                      // inner returned, its frame popped, y is GONE

int main(void) {
    outer();           // outer's frame pushed; when it returns, x is gone too
}

Every variable in the code above has a lifetime exactly matched to the function it lives in. The instant inner returns, y ceases to exist, from C's point of view. The bytes that held it are still there physically, in RAM. Nothing erases them. But they're unowned now, and the next function call can reuse that space for its own variables. Reading from a pointer to y after inner returns gives you whatever happens to be sitting in those bytes. Sometimes it's the old value. Sometimes it's nonsense. Sometimes it's another function's local variable, which you might also mess up if you write.

Watch frames grow and shrink.
Code
The stack top frame is the currently executing function
Click Next step or any step number below to watch stack frames push and pop.

Step through the code and watch each function call push a new frame. When the function returns, its frame is popped and all of its local variables vanish in that instant. Notice how y disappears the moment inner returns. Any pointer that was still aimed at y is now pointing at freed stack space. That's a dangling pointer, and it's the whole reason the next section of this post exists.


The returning-a-pointer-to-local trap

Here is the single most famous stack bug in C. A well-meaning programmer writes a function that needs to return a buffer, allocates the buffer as a local variable, and returns a pointer to it. Looks fine. Compiles fine. Breaks quietly.

char* greet(void) {
    char buffer[64];
    strcpy(buffer, "hello world");
    return buffer;   // BUG. buffer lives in greet's frame.
}                         // the moment we return, buffer is dead.

int main(void) {
    char* s = greet();
    printf("%s\n", s); // undefined behaviour. s is dangling.
}

When greet returns, its stack frame is popped. The array buffer no longer exists. The pointer we returned is still a number, an address that used to be valid, but whatever lives at that address now is none of our business. On many machines and compilers this will appear to work if you read the pointer fast enough, because nothing has overwritten those bytes yet. This is the scariest part. Tests pass, demos work, the bug ships, and then one day a new function call arrives at the same address and your string turns into a junk sequence of bytes. Good luck debugging.

Undefined behaviour does not mean "always crashes." It means "no guarantees." The scariest flavour is "works by accident," where the bug sneaks through review, passes every test, and breaks in production.

Any half-decent compiler will warn you about this. Listen to the warning. There are three normal ways to fix it.

Fix 1: let the caller own the buffer

void greet(char* out, size_t n) {
    snprintf(out, n, "hello world");
}

int main(void) {
    char s[64];
    greet(s, sizeof(s));  // s lives in main's frame, safe for the whole call
    printf("%s\n", s);
}

The caller allocates. The function fills it in. The buffer's lifetime is tied to main's frame, so it's valid until main returns. This is the most common pattern in modern C. snprintf, fgets, strncpy, and half the standard library work this way.

Fix 2: allocate on the heap

char* greet(void) {
    char* buffer = malloc(64);
    if (!buffer) return NULL;
    strcpy(buffer, "hello world");
    return buffer;  // heap memory. caller now owns it.
}

int main(void) {
    char* s = greet();
    printf("%s\n", s);
    free(s);  // caller must remember to free.
}

Now the string lives on the heap. Heap memory ignores function return. It sticks around until somebody explicitly frees it. The cost is that you've now passed ownership to the caller, and the caller has to remember to free, or you've got a memory leak.

Fix 3: use static storage

char* greet(void) {
    static char buffer[64];
    strcpy(buffer, "hello world");
    return buffer;  // static variable, lives for the entire program
}

A static local variable isn't on the stack. It lives in a different region of memory that persists for the entire program. Returning a pointer to it is safe from a lifetime standpoint. But the buffer is shared. Every call to greet overwrites the same memory. That's not thread-safe, it's not reentrant, and two nearby calls can stomp on each other. This is why classic C functions like strtok and asctime are the bane of multithreaded programs.

If a function returns a pointer, you owe the caller an answer to "where does this live and who frees it?" Every time.

Dynamic memory in C: malloc, calloc, realloc, free

Let's walk through the C heap API. It's four functions, and you can get very far with just two of them.

malloc

int* p = malloc(10 * sizeof(int));
if (!p) { /* allocation failed, handle it */ }
// p now points at 10 ints of uninitialised memory on the heap

You pass the number of bytes you want. malloc returns a void* that you store in a typed pointer (the conversion is implicit in C, explicit in C++). The memory you get is uninitialised. It holds whatever garbage was there before. Don't read from it until you've written something.

If allocation fails, usually because the system is out of memory, malloc returns NULL. Always check. In practice, for desktop programs, allocation failure is rare but possible, and silently proceeding with a null pointer is one of the faster ways to crash.

calloc

int* p = calloc(10, sizeof(int));
// 10 ints, all zero-initialised

Same thing as malloc, except it takes two arguments (count and element size) and zero-fills the memory before handing it to you. Use it when you want a fresh clean buffer. The zero-fill isn't free but it's usually cheap and often what you'd have done manually anyway.

realloc

int* tmp = realloc(p, 20 * sizeof(int));
if (tmp) {
    p = tmp;  // resize succeeded
} else {
    // resize failed. p is still valid at the old size!
}

Resizes an existing allocation. Might return the same pointer (if there was room to grow in place), or might return a new pointer to a different chunk (after copying your old data into it). Never write p = realloc(p, ...) directly. If realloc fails, it returns NULL but leaves your old allocation intact. Overwriting p with NULL would leak it. Always use a temporary.

free

free(p);
p = NULL;  // defensive: prevents accidental use-after-free

Releases an allocation back to the heap. After free, the pointer still holds its old address, but that address now belongs to the allocator again. Dereferencing it is undefined behaviour. A common defensive habit is to set the pointer to NULL immediately after freeing, because free(NULL) is safe (a no-op) and dereferencing NULL tends to crash loudly instead of corrupting silently.

A tiny but critical rule: every malloc/calloc/realloc must be matched by exactly one free. No more, no less.

Play with the heap. Allocate, free, leak, double-free.
size: bytes
Heap
allocated: 0B leaked: 0B blocks: 0
allocated freed (available) untouched

Click malloc() a few times to see blocks appear on the heap. Each gets a pointer handle. Click a pointer to select it, then click free() to release it. Try pressing free() twice on the same pointer to see a double-free flagged. Click malloc() without freeing anything to watch the leak counter grow. This is the mental model. Real allocators are smarter, but the shape of the story is the same.


Lifetime: the idea underneath it all

Everything we've covered so far folds into one concept. Every value in a program has a lifetime, a window of time during which its memory is valid. A pointer is safe to dereference only inside that window. Outside of it, the pointer is dangling, and using it is undefined behaviour.

For stack variables, the lifetime is from when you enter their scope (function entry, or block entry) to when you leave it (function return, or block exit). For heap allocations, the lifetime is from malloc to free. For static variables and globals, the lifetime is the entire program. String literals ("hello") also live in a static region, which is why returning one from a function is safe.

The mental model: every time you take a pointer, ask two questions. Where does the thing I'm pointing at actually live, and how long is that location valid? If you can answer both, you're safe. If you can't, you've found a bug waiting to happen.

Scrub through time. Watch the pointer go dangling.
t = 0
Drag the slider or press Play. Watch where the buffer lives and what the pointer points at as time advances.

Three scenarios, same pointer, different stories. In "return pointer to local," the buffer is born inside a function, dies the moment the function returns, and the pointer survives as dangling. In "heap allocation, freed too early," we free manually and then try to read anyway. In "safe: caller-owned buffer," the buffer outlives the function that fills it. Slide through time and watch when the pointer stops being valid.


A note on C++

Everything above applies to C++, with a couple of important additions. Instead of malloc/free, C++ has new/delete, which do the same thing but also call constructors and destructors. The rules about pairing allocations with frees still apply, just with the new names.

C
int* p = malloc(sizeof(int));
*p = 42;
free(p);
C++
int* p = new int(42);
// initialises on allocation
delete p;

The bigger C++ story is that modern C++ code rarely calls new or delete directly. Instead it uses smart pointers like std::unique_ptr and std::shared_ptr, which bundle allocation and deallocation together so that memory gets freed automatically when the smart pointer goes out of scope. This is the C++ answer to "how do I stop forgetting to free." We'll cover smart pointers properly in a later post. For now, know that they exist and they're the right default in modern C++.

One rule worth burning in now. Never mix allocators. Don't free something allocated with new. Don't delete something allocated with malloc. Don't delete something allocated with new[] (use delete[] instead). Each allocator has its own bookkeeping, and mixing them corrupts it.


Summing up

Memory in a C program lives in one of a few places. Locals live on the stack, automatically pushed and popped with function calls. Heap allocations live until you explicitly free them. Statics and globals live for the entire program. String literals too. Every pointer points into one of these regions, and its safety depends on that region still being valid when you read through it.

The common failure modes come directly from getting lifetime wrong:

  • Returning a pointer to a local is a dangling pointer because the stack frame dies at return.
  • Reading through a pointer after free is use-after-free, undefined behaviour.
  • Forgetting to free a heap allocation leaks memory, which is slow poison in long-running programs.
  • realloc can move the allocation. Always store its return in a temporary before overwriting the old pointer.

If you remember one thing from this post, remember the question. Where does this live and how long is it valid? Every pointer deserves an answer.


What's next

Now that lifetime is a concrete idea, we can finally talk about the three specific flavours of broken pointer you'll spend years debugging. The null pointer, which politely points at nothing. The wild pointer, which was never pointed anywhere on purpose. And the dangling pointer, which used to be valid and now isn't. Next post we'll pick them apart, learn to recognise each one in code reviews, and build defensive habits that stop them before they ship.

Every pointer bug is a version of the same question asked wrong. Next post: the three villains.

Test yourself

Seven questions on what you just read. Each one probes a specific trap the post tried to burn into your brain. Get at least five and you're ready for Part 5. Get stuck on two or more, go back and re-read the lifetime section.

Q1
Which memory region does each of these live in?
int g = 10;                  // (1)

void foo(void) {
    int x = 5;              // (2)
    int* p = malloc(16);    // (3) the pointer variable p itself
                               // (4) the 16 bytes that p points at
    static int s = 0;       // (5)
    const char* msg = "hi";  // (6) the pointer variable msg itself
                               // (7) the string literal "hi"
}
(1) static · (2) stack · (3) stack · (4) heap · (5) static · (6) stack · (7) read-only static data (1) g is a global, so it lives in the static data section (often called .data). Lifetime: entire program.
(2) x is a plain local, so it lives on the stack. Dies when foo returns.
(3) The pointer variable p is also a local, so it's on the stack. Don't confuse the pointer with what it points at.
(4) What p points at is the 16 bytes returned by malloc, which is on the heap.
(5) s is declared static inside a function, which puts it in the static data section, not the stack. Lifetime: entire program. Only its visibility is limited to this function.
(6) msg is a local variable holding a pointer, so the pointer itself lives on the stack.
(7) The string literal "hi" lives in read-only static data (often .rodata). Trying to modify it is undefined behaviour. That's why the pointer type should be const char*.
Q2
What's wrong with this code? Specifically, what happens when main tries to print s?
char* make_name(void) {
    char buf[32];
    strcpy(buf, "Alice");
    return buf;
}

int main(void) {
    char* s = make_name();
    printf("%s\n", s);
}
Dangling pointer. Returns a pointer to a dead stack frame. buf lives in make_name's stack frame. When make_name returns, that frame is popped and buf no longer exists. The address returned is still a valid number, but the memory it points at is no longer ours. This is undefined behaviour, and the specific symptom is unpredictable. It might print "Alice" if nothing has overwritten those bytes yet. It might print garbage. It might crash. It might appear to work in development and corrupt data in production. The scary part is that the bug often hides until the worst possible moment. Fixes: caller-owned buffer, heap allocation, or static storage.
Q3
Which of these are undefined behaviour? Select all that apply.
Answers: A, B, D. free(NULL) is safe. A is UB (double-free). The allocator's bookkeeping gets corrupted, usually causing a crash later in an unrelated allocation.
B is UB (use-after-free). The memory has been returned to the allocator and may already belong to a different allocation.
C is safe. The C standard explicitly defines free(NULL) as a no-op. This is why "set pointer to NULL after freeing" is a safe defensive pattern.
D is UB (mismatched allocators). malloc/free and new/delete are separate allocators with separate bookkeeping. Never cross the streams.
Q4
What's wrong with this realloc pattern? What specific bug does it have?
int* p = malloc(10 * sizeof(int));
// ... use p ...
p = realloc(p, 20 * sizeof(int));
// ... use p again ...
Memory leak on realloc failure. If realloc fails (system out of memory, for instance), it returns NULL but leaves your original allocation intact. Writing p = realloc(p, ...) overwrites p with NULL, and now you've lost your only pointer to the old allocation. It's leaked. You can't free it. It'll sit there until the program ends. The safe pattern is to assign to a temporary first, check for success, and only overwrite p if the realloc succeeded:
int* tmp = realloc(p, 20 * sizeof(int));
if (tmp) p = tmp;
else { /* handle failure, p is still valid */ }
Q5
Suppose you write a function that needs to return "the third word in a given string." Which of the three fix strategies (caller-owned buffer, heap allocation, static storage) would you pick, and why? What are the trade-offs?
There's no single right answer, but each has a distinct trade-off. Caller-owned buffer is usually the cleanest for small/medium outputs. Fast (stack), no heap traffic, no ownership confusion. Downside: caller has to pre-size the buffer, and if they get it wrong you need graceful truncation.
Heap allocation is best when the output size depends on input and you don't want the caller guessing. Downside: caller now owns an allocation they must remember to free. Creates ownership ambiguity in APIs.
Static storage is the laziest, and usually wrong for library code. Works fine in single-threaded toy programs, but the returned pointer is shared across all calls and all threads. Second call stomps the first. This is why strtok has a bad reputation and why modern versions (strtok_r) take a caller-owned buffer.
For "third word in a string," a caller-owned (char* out, size_t n) API is the most idiomatic and reusable choice.
Q6
A colleague runs the dangling-pointer program from Q2 and says "I tried it three times and it prints 'Alice' every time. The bug isn't real." How do you respond?
"Works on my machine" is not a guarantee. It's UB, and UB doesn't mean "always crashes." Your colleague is observing a real phenomenon but drawing the wrong conclusion. Right after make_name returns, nothing has overwritten the stack bytes where buf used to live, so those bytes still contain "Alice". printf reads them fast enough to see the original data, and you get the "expected" output.

The bug is still there. Add one more function call between make_name() and printf, let the optimiser inline something, switch compilers, switch operating systems, enable stack protectors, or just do it on a busier stack, and the dangling pointer will start pointing at junk. The output can become garbage, crash, or worse, silently corrupt something else in the program.

The general lesson: undefined behaviour is not "always crashes." It's "no guarantees." The nastiest bugs in C are the ones that happen to work during development and break for a user six months later. The compiler is legally allowed to do anything with UB code, including delete the code entirely, assume the UB never happens, or produce different behaviour across runs. Tools like AddressSanitizer and Valgrind exist precisely to catch these bugs before they ship.
Q7
True or false, with a reason for each.

Select the ones that are true.

True: A, C, E. False: B, D. A true. Stack allocation is basically a register adjust, no bookkeeping. Heap involves allocator data structures and sometimes system calls.
B false. Heap memory only comes back when you call free. Function return reclaims stack, not heap. This is exactly why memory leaks exist.
C true. This happens constantly. int* p = malloc(...) puts p on the stack and the 16 bytes on the heap. They're separate.
D false. malloc returns uninitialised memory. Use calloc if you want zero-fill.
E true. String literals are stored in a read-only static region. That's why returning one from a function is safe, and why modifying one (char* s = "hi"; s[0] = 'H';) is undefined behaviour.
How did you do?
5 or more correct, you're ready for Part 5. Less than that, the usual trouble spots are Q3 (what's UB, what's safe) and Q4 (the realloc pattern). Both are small ideas with big consequences. Re-read them and the lifetime section, then try again.

Comments