Skip to main content

Command Palette

Search for a command to run...

Stack and Heap in Go: The Complete Guide

Published
13 min read
A

Full Stack Developer specializing in sleek, performant frontends and scalable backend systems I build production ready web applications with a focus on scalability, performance, and clean architecture. My expertise spans modern frontend development with React.js , Next.js and TypeScript, combined with robust backend systems.

On the backend, I specialize in Golang microservices using Fiber framework, implementing event-driven architectures with Kafka, caching strategies with Redis, and building efficient APIs with gRPC. I focus on creating scalable, maintainable systems that handle real-world complexity.

What Are Stack and Heap?

First, understand this clearly: stack and heap are NOT in your executable file. They don't exist on disk. They are memory regions that the Go runtime creates in RAM after your program starts running.

Think of it this way: your executable file contains code, global variables, and metadata (these come from the linker). But stack and heap are runtime decisions—the runtime creates them dynamically when your program needs them. They're carved out of your process's virtual memory address space that the operating system provides.

So the mental model is:

  • TEXT and DATA sections → come from the executable file (static, on disk)

  • Heap and Stack → created by the runtime in RAM (dynamic, during execution)

They are completely separate concepts. Your executable contains the instructions that will eventually create and manage heap and stack, but the heap and stack themselves only exist when your program is running.

How Runtime Creates Heap and Stack

When the Go runtime finishes its bootstrap initialization (after schedinit runs), it immediately performs two critical steps to prepare for running your code.

Step 1: Create the Heap

The runtime asks the operating system for a large region of virtual memory. On a 64-bit system, this might reserve terabytes of address space (though it doesn't actually use physical RAM until you allocate objects). The runtime sets up its internal heap management structures—spans, arenas, size classes, caches—all the machinery that makes make and new work. Initially, the heap is empty. No objects exist yet. It's just address space waiting to be filled.

Step 2: Create the First Stack

The runtime creates one stack for the very first goroutine, the main goroutine that will run your main.main function. This stack is tiny, just 2 KB to start. It's a private memory region belonging only to that one goroutine. The runtime allocates this stack by requesting memory pages from the operating system, completely separate from the heap.

So the sequence is: heap exists first, then the first stack is created immediately after. Both are runtime creations that happen during the bootstrap phase, before your main.main function ever runs.

Where Does Stack Memory Come From?

This is crucial to understand correctly because many developers get confused here.

Stack memory comes directly from the operating system, NOT from the heap. This is a common misconception that will hurt you in technical interviews if you get it wrong.

Here's what actually happens: when the runtime needs stack memory (either for the first goroutine or when a stack needs to grow), it makes a system call to the operating system asking for memory pages. On Linux, this is typically mmap. On Windows, it's VirtualAlloc. The OS gives the runtime some pages of memory, and the runtime uses those pages as stack space.

The heap is a completely separate region. The runtime also gets heap memory from the OS using similar system calls, but heap and stack are tracked separately, managed by different runtime code, and never mixed. They're independent memory regions in your process's address space.

Why does this matter? Because stack and heap have completely different lifetimes and management strategies. Stack memory is tied to function call lifetimes—when a function returns, its stack space is immediately available for reuse. Heap memory lives until the garbage collector proves an object is unreachable. They're fundamentally different systems that happen to both use OS memory as their source.

Why Do Goroutines Use Stacks Instead of Heap?

Every goroutine needs its own stack. But why use stacks at all? Why not allocate everything on the heap?

The answer is that function execution fundamentally requires a stack. When you call a function, the program needs space to store several critical things: the function's parameters, its local variables, its return values, and the return address (where to jump back to when the function finishes). This data needs to be organized in a specific way—a stack structure—where the most recently called function's data is on top, and when that function returns, its data disappears instantly.

Using the heap for function call data would be disastrous. It would be incredibly slow because heap allocation requires coordination with the memory allocator and potentially the garbage collector. It would create enormous GC pressure because every function call would create heap objects that need to be tracked and eventually collected. It would be unsafe for the call-return pattern because there's no automatic cleanup when functions return. The stack, on the other hand, gives you extremely fast allocation (just move a pointer), automatic cleanup (when a function returns, its stack frame is gone), and completely predictable lifetimes.

That's why every goroutine has its own stack. It's not optional—it's how function calls work at the machine level.

Default Goroutine Stack Size

In current versions of Go, each goroutine starts with a very small stack: 2 KB (2,048 bytes). This is the initial allocation. Compare this to operating system threads, which typically start with 8 MB stacks on Linux. That's four thousand times larger!

Why does Go use such tiny stacks? Because Go is designed for massive concurrency. The language expects you to create thousands, tens of thousands, even millions of goroutines. Most goroutines do very little work—they might handle one HTTP request, process one message, or wait on one channel. If Go gave every goroutine an 8 MB stack like OS threads have, you would immediately run out of memory. Ten thousand goroutines with 8 MB stacks each would require 80 GB of RAM just for stacks, before your program does any actual work.

So Go makes a trade-off: start with an extremely small stack, knowing that most goroutines will never need more, and for those that do need more, grow the stack dynamically. This is one of the key innovations that makes Go's concurrency model practical.

How Stacks Grow - The Key Mechanism

Go stacks are not fixed size like C or Java stacks. They grow automatically when needed. This is critical to how Go works, so let me explain the complete mechanism.

When you compile a Go function, the compiler inserts a small piece of code at the very beginning of that function called a stack check or stack guard check. This code looks at how much stack space the function will need for its local variables and compares it to how much stack space is currently available. If there's enough space, the function proceeds normally. If there's not enough space, the function immediately calls into the runtime's stack growth code before executing any of its own logic.

Here's what happens when a stack needs to grow. The runtime first allocates a new, bigger stack—typically twice the size of the current stack, though the exact growth factor can vary. Then comes the tricky part: the runtime copies all the data from the old stack to the new stack. This isn't just a simple memory copy because stacks contain pointers—addresses that point to other locations on the stack. When you move the stack to a new location in memory, all those pointers become invalid. So the runtime has to walk through the stack frame by frame, find every pointer, and adjust it to point to the new location. The compiler and linker provided stack maps (part of that metadata embedded in your executable) that tell the runtime exactly where pointers are in each stack frame, making this adjustment possible.

Once the old stack's data has been copied and all pointers have been updated, the runtime switches the goroutine to use the new stack and frees the old stack memory. Then the function that triggered the growth continues executing, completely unaware that anything happened. From your code's perspective, the stack just had enough space. The growth is invisible.

This growth pattern happens in chunks. Stacks don't grow by tiny amounts—they double in size or grow by some significant factor. A stack might go from 2 KB to 4 KB to 8 KB to 16 KB as a goroutine needs more space. This happens automatically and is completely managed by the runtime.

Why can't the stack just extend in place? Because there might not be free, contiguous memory immediately after the current stack. Your process's memory is shared among many things—heap, other stacks, runtime data structures. The runtime can't assume the memory right after a stack is available, so it can't safely extend the stack in place. Instead, it allocates a completely new region and copies everything over.

How Stacks Shrink

Stack growth is common and automatic. Stack shrinking is much rarer and more opportunistic.

When a goroutine finishes executing deep function calls and returns to a shallow call depth, it might be using only a small fraction of its allocated stack space. For example, a goroutine might have grown to a 32 KB stack during some heavy processing, but now it's back to shallow calls and only using 2 KB of that space. The remaining 30 KB is wasted.

The Go runtime can shrink stacks, but it doesn't do this eagerly. Stack shrinking only happens during garbage collection cycles as an opportunistic optimization. The GC scans stacks anyway to find pointers, and while it's doing that work, it checks if any stacks are significantly oversized for their current usage. If a stack is using less than 25% of its allocated space, the runtime might decide to shrink it by allocating a smaller stack, copying the active data, and freeing the old stack.

However, many goroutines never shrink their stacks during their entire lifetime. If a goroutine grew to 16 KB once and stays at shallow depth afterward, it might just keep that 16 KB stack until it terminates. Stack shrinking is a memory optimization, not a core feature like growth. Don't rely on it happening—think of it as a bonus the runtime provides when it's convenient.

Stack vs Heap - The Critical Distinction

Even though both stack and heap memory ultimately come from the operating system's memory management, they are logically and physically separate in your running program.

Stack memory is obtained directly from the OS when the runtime needs it for goroutine stacks. It's tracked in runtime data structures separate from the heap. It's managed by different code—stack growth, stack shrinking, stack copying. It's NOT scanned by the garbage collector the same way heap objects are (though the GC does scan stacks looking for pointers to heap objects). Each goroutine's stack is private to that goroutine—no sharing ever happens.

Heap memory is obtained from the OS and managed by the heap allocator and garbage collector. It's where all dynamically allocated objects live—things created with make and new, things that escape to the heap during escape analysis. The heap is shared by all goroutines. When one goroutine allocates an object on the heap, any other goroutine can access it (if it has a pointer to it).

The key distinction: stacks are private, heap is shared. Stacks have automatic lifetime management tied to function calls. Heap has garbage-collected lifetime management tied to reachability.

One Executable, Many Stacks

Here's a point of confusion for beginners: you have one executable file, but at runtime you can have thousands of stacks. How is that possible?

The executable file is just code and data on disk. It contains the instructions that will run, but it's not the running program itself. When your program runs, the Go runtime creates a process with one heap (shared by everything) and many stacks (one per goroutine).

Think of a web server. Your executable file might be 10 MB on disk. But when it runs and handles HTTP requests, you might have:

  • One executable loaded into memory

  • One process running

  • One heap (shared) where all allocated objects live

  • Stack for goroutine #1 (the main goroutine running main.main)

  • Stack for goroutine #2 (HTTP handler for request A)

  • Stack for goroutine #3 (HTTP handler for request B)

  • Stack for goroutine #4 (database call goroutine)

  • Stack for goroutine #5 (background cleanup task)

  • ... potentially thousands more

Every go func() call creates a new goroutine, and every new goroutine gets its own private stack. They all share the same heap for allocated objects. They all run the same executable code. But each has its own execution stack.

Quick Reference: Key Questions Answered

Does each goroutine have its own stack?

Yes. Always. Every goroutine gets one private stack. Stacks are never shared between goroutines. This is fundamental to how goroutines work.

If main calls normal functions, where do they run?

They run on the same stack as main. Normal function calls don't create new goroutines, so they use the calling goroutine's stack.

Example:

func main() {
    a()  // runs on main's stack
    b()  // runs on main's stack
}

The call stack looks like: main → a → b, all on one stack.

What happens when you use the go keyword?

A new goroutine is created with a new stack. The go keyword is the only way to create a new goroutine, and every new goroutine gets its own stack.

Example:

func main() {
    a()     // same stack as main
    go b()  // NEW goroutine → NEW stack
}

Memory view:

  • Stack 1 (main goroutine): main → a

  • Stack 2 (goroutine running b): b

  • Heap (shared by both)

Where do shared or returned values go?

This depends on escape analysis, but the general rule is: if a value needs to outlive its function or be shared between goroutines, it goes to the heap. If a value is only used locally within one goroutine's function calls, it stays on the stack.

Examples:

func local() {
    x := 42  // stays on stack (local, doesn't escape)
    fmt.Println(x)
}

func escapes() *int {
    x := 42
    return &x  // x escapes to heap (outlives the function)
}

func shared() {
    ch := make(chan int)  // channel goes to heap (shared between goroutines)
    go func() {
        ch <- 42
    }()
}

The heap is shared. Stacks are private. When data needs to cross goroutine boundaries, it lives on the heap.

How does the runtime know a stack needs to grow?

The compiler inserts stack-size checks at the beginning of every function. Before a function executes its body, it checks: "Do I have enough stack space for my local variables?" If the answer is no, it calls the runtime's stack growth code, which allocates a bigger stack, copies everything over, updates pointers, and then lets the function continue. This check happens automatically—you never write this code yourself. The compiler inserts it during compilation.

The Mental Model - Lock This In

Here's what you need to remember:

Normal function calls stay on the same stack. When main calls a calls b, all three functions execute on the main goroutine's stack. The stack grows deeper with each call, then shrinks as functions return.

The go keyword creates a new goroutine with a new stack. Every time you write go someFunc(), you're creating a completely separate execution context with its own private stack.

The heap is used when data must live longer than a function call or be shared across goroutines. If the compiler's escape analysis determines a value escapes its function, that value is allocated on the heap instead of the stack.

Goroutines never share stacks. They communicate through heap-allocated data structures like channels (which the runtime manages), shared variables (protected by mutexes), or other synchronization primitives. But the stacks themselves are always private.

Stack memory comes from the OS, not from the heap. This is a critical distinction. The runtime requests memory pages from the operating system for both heap and stack, but they're completely separate regions managed by different systems.

At runtime, the Go scheduler can create thousands of goroutines based on your code, and each goroutine gets its own separate stack that starts small (2 KB), grows automatically when needed by copying to a larger region, and occasionally shrinks during garbage collection if it's wasting space. Meanwhile, all goroutines share one heap where dynamically allocated objects live under garbage collector management. This design—small private stacks plus a shared heap—is what makes Go's concurrency model efficient enough to handle massive parallelism.