How Goroutine Stack Growth Works in Go

Go goroutine stacks grow and shrink automatically at runtime to optimize memory usage for concurrent tasks.

You write a recursive function that digs deep into a tree structure

In C, you calculate the stack size carefully or risk a segfault. In Java, you tune the JVM flags to prevent StackOverflowError. In Go, you just run it. The goroutine starts with a tiny stack, grows as the recursion deepens, and shrinks back down when it returns. You never touch a stack size flag. This behavior is what lets Go run millions of concurrent tasks on a single machine without exhausting memory.

Stacks that stretch and shrink

Every goroutine gets its own stack. The stack holds local variables, function arguments, and return addresses. In many languages, the stack size is fixed at creation. If you exceed it, the program crashes. Go takes a different approach.

Goroutine stacks start tiny, usually 2 kilobytes on modern versions. When a goroutine needs more space, the runtime allocates a larger stack, copies the old stack to the new one, and continues execution. When the stack usage drops, the runtime can shrink it back down to reclaim memory. This dynamic behavior means memory usage matches actual demand, not worst-case guesses.

Think of a stack like a backpack with elastic sides. You start with a small bag. If you need to carry more gear, the bag stretches to fit. When you empty it, the bag shrinks back to its original size. You never need to buy a new bag or worry about the exact capacity until you actually fill it.

Stacks grow when you need space. They shrink when you don't. You just write code.

A minimal example

The following code shows a recursive function that would crash in languages with fixed stack sizes. Go handles the growth automatically.

package main

import "fmt"

// DeepRecursion demonstrates stack growth without manual tuning.
// The runtime expands the stack automatically as the call depth increases.
func DeepRecursion(n int) {
	if n == 0 {
		fmt.Println("Reached bottom")
		return
	}
	// Each call adds a frame to the stack.
	// If the stack fills up, Go grows it transparently.
	DeepRecursion(n - 1)
}

func main() {
	// This would crash in C with a default stack size.
	// Go handles the growth automatically.
	DeepRecursion(100000)
}

The function calls itself 100,000 times. Each call adds a frame containing the return address and the local variable n. The initial 2KB stack fills up quickly. The runtime detects the overflow, allocates a larger stack, copies the data, and resumes. This happens multiple times as the recursion deepens. When the base case is hit and the function returns, the stack usage drops. The runtime may shrink the stack to free memory.

How the runtime manages the stack

The compiler generates code that checks stack space before every function call. If the current stack has enough room, the call proceeds normally. If not, the compiler emits a call to a runtime function, historically known as morestack.

The runtime allocates a new stack, typically doubling the size of the current one. It then copies the contents of the old stack to the new one. This copy isn't a blind memory dump. The runtime adjusts pointers to local variables so they point to the correct locations in the new stack. Pointers to heap objects remain valid because they point outside the stack. Once the copy finishes, execution resumes with the new stack active. The program never sees the interruption.

When a goroutine returns from a deep call and the stack usage falls below a threshold, the runtime may shrink the stack. It allocates a smaller stack, copies the active portion, and frees the larger one. This keeps memory footprint low for idle or shallow goroutines. The runtime tracks the high watermark of stack usage to decide when shrinking is safe.

The runtime copies pointers. You don't manage memory. Focus on logic.

Escape analysis and stack pressure

Stack growth works hand-in-hand with escape analysis. The compiler analyzes your code to decide where variables live. If a variable's address is taken and stored in a global variable, returned from a function, or passed to a goroutine, it escapes to the heap. Heap-allocated variables don't consume stack space.

This matters for performance. Large structs that escape don't bloat the stack. If you write code that forces large values to stay on the stack, you increase the pressure on stack growth. Frequent stack growth involves copying memory and adjusting pointers, which adds overhead. Writing code that allows the compiler to move data to the heap can reduce stack churn.

You can inspect escape analysis by running go build -gcflags="-m". The output shows which variables escape and which stay on the stack. This helps you understand allocation patterns without guessing.

Realistic scenario: high-concurrency server

Consider a web server handling thousands of simultaneous requests. Each request runs in its own goroutine. Some requests are simple lookups that use minimal stack space. Others trigger deep processing chains.

package main

import (
	"fmt"
	"net/http"
)

// ProcessRequest handles an HTTP request in a new goroutine.
// The stack starts small and grows only if the request requires deep processing.
func ProcessRequest(w http.ResponseWriter, r *http.Request) {
	// Local variables live on the stack.
	// The runtime tracks usage and grows the stack if needed.
	var buffer [1024]byte
	fmt.Fprintf(w, "Handled request with buffer size %d\n", len(buffer))
}

func main() {
	http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		// net/http spawns a goroutine per request.
		// Each goroutine gets its own dynamic stack.
		go ProcessRequest(w, r)
	})
	fmt.Println("Server running on :8080")
	http.ListenAndServe(":8080", nil)
}

The net/http server spawns a goroutine for each incoming request. If every goroutine required a fixed 1MB stack, 10,000 concurrent connections would need 10GB of RAM. With 2KB initial stacks, 10,000 goroutines use about 20MB. Only the goroutines that actually need more stack grow larger. This efficiency solves the C10K problem without manual tuning. The server scales to millions of connections because memory usage stays proportional to actual work, not connection count.

Pitfalls and stack limits

Dynamic stacks don't mean infinite stacks. The runtime imposes a hard limit, usually 1 gigabyte. If a goroutine hits this limit, the program panics with fatal error: stack overflow. This stops the program immediately.

Stack overflows usually indicate infinite recursion or a bug in stack growth logic. You can also trigger a stack overflow by allocating massive arrays on the stack. The compiler tries to move large allocations to the heap via escape analysis, but if you force a value to stay on the stack or escape analysis fails to move it, you might exhaust the limit.

Another pitfall involves taking the address of a local variable and storing it somewhere long-lived. If the variable escapes to the heap, it no longer lives on the stack. This is safe, but it changes memory allocation patterns. The compiler handles this automatically, but understanding escape analysis helps predict heap vs stack usage.

When debugging, you can capture stack traces using runtime.Stack. This function writes the stack trace of all goroutines to a buffer. It's useful for finding deadlocks or deep recursion bugs in production.

Infinite recursion still crashes. Fix the algorithm, not the limit.

When to use goroutines vs alternatives

Go's stack management makes goroutines the default choice for concurrency, but other patterns exist for specific needs.

Use a goroutine when you need concurrency and the runtime can manage stack growth automatically. Use an iterative loop when recursion depth is unbounded or likely to exceed reasonable stack limits. Use heap allocation when you need to share data across goroutines or the data size is unpredictable and large. Use an OS thread when you need to call C code that requires a fixed stack size or when you need precise control over stack memory layout. Use a worker pool when you need to bound the number of concurrent tasks to protect downstream resources.

Goroutines handle the stack. You handle the design.

Where to go next