How much memory does a goroutine use

Goroutines start with a 2 KB stack that grows and shrinks dynamically to optimize memory usage.

You spawned a million goroutines and the server died

You are building a high-throughput proxy. The documentation says goroutines are lightweight. You decide to spawn one goroutine per incoming connection. Traffic spikes. Your server consumes all available RAM and the operating system kills the process. You did not write a memory leak. You did not allocate massive buffers. You simply misunderstood the cost model.

Goroutines are cheap compared to OS threads, but they are not free. Every goroutine reserves memory the moment it starts. The runtime allocates a stack for each goroutine. If you spawn enough goroutines, the stack memory adds up fast. One million goroutines with the minimum stack size can consume gigabytes of memory before they do any work.

The stack lives on the heap

A goroutine needs a stack to store local variables, function arguments, and call frames. The Go runtime manages this stack automatically. When you create a goroutine, the runtime allocates a small initial stack. The default size is 2 KB on most 64-bit systems. This stack lives on the heap, not on an OS-managed stack.

This design differs from OS threads. An OS thread usually gets a fixed stack size of 1 MB or 8 MB that stays allocated for the lifetime of the thread. If you create a thousand threads, you reserve gigabytes of address space immediately. Goroutines start small. The runtime grows the stack only when the goroutine needs more space. When the stack is no longer needed, the runtime shrinks it back down.

Think of the stack like a notepad. You start with a small notepad. If you need to write more, you swap it for a larger one. When you finish a task, you can go back to the small notepad. The runtime handles the swapping. You never see the larger notepad unless you look closely.

Measuring the cost

You can measure the memory impact of goroutines using the runtime package. The runtime.MemStats structure tracks heap allocations. By comparing memory usage before and after spawning goroutines, you can see the baseline cost.

package main

import (
	"fmt"
	"runtime"
)

// main measures the memory cost of spawning goroutines.
func main() {
	// Force garbage collection to get a clean baseline.
	runtime.GC()

	var m1 runtime.MemStats
	runtime.ReadMemStats(&m1)

	// Spawn one million goroutines that return immediately.
	// Each goroutine allocates a stack but does no work.
	for i := 0; i < 1_000_000; i++ {
		go func() {
			// This function returns immediately.
			// The stack stays at the minimum size.
		}()
	}

	// Yield to let the scheduler start the goroutines.
	runtime.Gosched()

	// Force GC again to account for any deferred cleanup.
	runtime.GC()

	var m2 runtime.MemStats
	runtime.ReadMemStats(&m2)

	// Calculate the difference in heap allocations.
	// TotalAlloc is cumulative, so the difference shows new allocations.
	allocated := m2.TotalAlloc - m1.TotalAlloc
	fmt.Printf("Total allocated: %d bytes\n", allocated)
	fmt.Printf("Per goroutine estimate: %d bytes\n", allocated/1_000_000)
}

The output shows that each goroutine costs more than just the stack. The runtime also allocates a g structure to track the goroutine's state. The g structure is small, usually a few hundred bytes. Combined with the 2 KB stack, the total cost per goroutine is roughly 2.5 KB. One million goroutines consume about 2.5 GB of heap memory.

Goroutines are cheap. They are not free.

What happens when the stack grows

The initial stack is small because most goroutines do not need much space. A goroutine that calls a few functions and returns quickly stays within the 2 KB limit. When a goroutine needs more space, the runtime grows the stack.

Go uses a copying stack model. When the stack needs to grow, the runtime allocates a new, larger stack on the heap. It copies the contents of the old stack to the new one. It updates all pointers to the stack so that the goroutine continues running without noticing the move. The old stack becomes garbage and is reclaimed by the garbage collector.

This mechanism is invisible to your code. You do not need to manage stack sizes. The runtime handles the growth and shrinkage. The cost is amortized. Stack growth happens rarely for most workloads. When it does happen, the copy operation takes time proportional to the stack size. Deep recursion or large local arrays can trigger frequent stack growth, which adds latency.

Large allocations do not blow up the stack. If a goroutine allocates a large slice or map, the data goes to the heap. The stack only holds a pointer to the heap data. A goroutine can process a 1 GB file without a 1 GB stack. The stack stays small. The heap grows. This separation allows goroutines to handle large data without exhausting stack memory.

Stack holds pointers. Heap holds data. Keep stacks small.

Real-world memory management

In a production server, you rarely spawn goroutines in a tight loop. You spawn them to handle requests. Each request handler runs in its own goroutine. The stack usage depends on what the handler does. A simple handler that reads a request and writes a response uses very little stack. A handler that parses a complex JSON payload or performs deep recursion uses more.

You can inspect the stack size of a goroutine using runtime.StackSize. This function returns the current size of the stack for the calling goroutine. It is useful for debugging memory usage or verifying that stack growth behaves as expected.

package main

import (
	"fmt"
	"runtime"
)

// checkStack demonstrates inspecting stack size.
func checkStack() {
	// runtime.StackSize returns the current stack size in bytes.
	// This is the size of the stack allocation, not the used portion.
	size := runtime.StackSize()
	fmt.Printf("Current stack size: %d bytes\n", size)

	// Allocate a large slice to force stack growth if needed.
	// Large allocations go to the heap, so the stack may not grow.
	// However, the slice header lives on the stack.
	buffer := make([]byte, 64*1024)
	_ = buffer[0]

	// Check the stack size again.
	// The stack size likely remains the same because the data is on the heap.
	fmt.Printf("Stack size after allocation: %d bytes\n", runtime.StackSize())
}

Goroutine leaks are a common source of memory growth. A goroutine leak happens when a goroutine blocks forever and never returns. The stack remains allocated. If you spawn goroutines that leak, memory usage climbs until the server crashes. A leak often occurs when a goroutine waits on a channel that never receives a value, or when a goroutine holds a reference to a resource that prevents garbage collection.

Use context.Context to provide a cancellation path for long-running goroutines. The context carries a deadline or cancellation signal. When the context is cancelled, the goroutine should check the context and return. This prevents leaks and frees the stack memory.

package main

import (
	"context"
	"fmt"
	"time"
)

// processTask simulates a long-running task that respects cancellation.
// The context is the first parameter, following Go convention.
func processTask(ctx context.Context, id int) {
	// Simulate work with a select statement.
	select {
	case <-time.After(5 * time.Second):
		fmt.Printf("Task %d completed\n", id)
	case <-ctx.Done():
		// The context was cancelled. Return immediately.
		// This releases the goroutine and its stack.
		fmt.Printf("Task %d cancelled: %v\n", id, ctx.Err())
	}
}

Context is plumbing. Run it through every long-lived call site.

Pitfalls and runtime panics

Stack overflow is a runtime panic, not a compile error. If a goroutine grows its stack beyond the limit, the runtime stops the program. The default stack limit is 1 GB. You hit this limit with deep recursion or by allocating massive arrays on the stack. The error message is fatal error: stack overflow.

Recursion without a base case is the most common cause. A function that calls itself indefinitely grows the stack until it hits the limit. The runtime detects the overflow and panics. You can catch the panic with recover, but a stack overflow usually indicates a logic error. Fix the recursion or rewrite the algorithm iteratively.

Loop variable capture is another pitfall when spawning goroutines. If you spawn goroutines in a loop and capture the loop variable, all goroutines may see the final value of the variable. Go 1.22 changed this behavior. The compiler now rejects code that captures a loop variable in a goroutine without explicit capture. The error is loop variable i captured by func literal. Fix this by passing the variable as an argument to the goroutine function.

package main

import "fmt"

// main demonstrates correct loop variable capture.
func main() {
	for i := 0; i < 5; i++ {
		// Pass i as an argument to capture the current value.
		// This avoids the loop variable capture error in Go 1.22+.
		go func(val int) {
			fmt.Println(val)
		}(i)
	}
}

Goroutine leaks are harder to detect than stack overflows. A leak does not panic. It slowly consumes memory. Use tools like pprof to inspect goroutine stacks and find blocked goroutines. Look for goroutines waiting on channels or locks. Ensure every goroutine has a way to exit.

The worst goroutine bug is the one that never logs.

When to use goroutines

Goroutines are a powerful concurrency primitive. They are not a replacement for careful design. Use them when they match the problem.

Use a goroutine when you have independent I/O calls that can run while others wait. Use a worker pool when you need bounded concurrency to protect a downstream service or limit memory usage. Use sequential code when the task is CPU-bound and small; the overhead of scheduling a goroutine outweighs the benefit. Use runtime.StackSize or runtime.MemStats when debugging memory usage or stack growth in a specific goroutine.

Bounded concurrency saves memory. Unbounded concurrency crashes servers.

Where to go next